diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ea674146..689422754 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,75 @@
# Changelog
+## [1.41.0] — 2026-10-07
+
+The Coach reasons before it answers, shows what it is doing while it works,
+asks when a question is ambiguous, and remembers what it is told.
+
+### Added
+
+- **Reasoning.** Codex, OpenAI, Anthropic, OpenRouter, gateways and local
+ servers receive the requested reasoning in their documented dialect;
+ reasoning summaries stream to the Coach, signed or encrypted reasoning state
+ is carried between tool rounds and never stored, and a model that rejects a
+ reasoning parameter is retried without it (the downgrade is learned per
+ provider, endpoint and model for an hour, only from a 400 that names the
+ parameter). Thinking depth per person under Coach settings (Off, Low,
+ Medium, High; Medium by default); an operator switch and maximum under
+ Admin, Assistant; turns on the operator's budget are capped at Medium.
+ `/api/auth/me` publishes the resolved `coachReasoning`.
+- **Budgeted tool loop.** A turn runs against a token, time and round budget
+ (own provider 120k tokens, 150 s, 12 rounds; operator 40k, 90 s, 6 rounds)
+ with a no-progress brake and a reserved final round that keeps the tool
+ definitions with `tool_choice: none`. Every round is reserved against the
+ daily budget beforehand and settled afterwards. At most two concurrent turns
+ per person.
+- **Live trail.** One quiet status line per answer (`activity` frames), never
+ expanded by itself; the screened reasoning text is stored encrypted
+ (`coach_messages.trail_encrypted`) and served lazily by
+ `GET /api/insights/chat/{id}/messages/{messageId}/trail`.
+- **Comparisons.** `compare_series` (two periods or two metrics) and a
+ `compare` chart; tables arrive as interim results before the answer.
+- **Clarifications** as suggested replies in the thread, at most one in six
+ turns and three a day; a declined or limited question becomes a stated
+ assumption with a reply that changes it.
+- **Memory and plans.** Every turn carries an egress-checked memory block;
+ `remember_fact` saves preferences, goals and context with a visible note and
+ undo, health facts only after the person agrees; `propose_plan` suggests a
+ plan from a stated goal. Both are written only when the answer that shows
+ them is stored. Background extraction runs once a conversation is quiet for
+ 30 minutes; plan progress reaches the daily briefing. The memory list in
+ settings shows each fact's source and accepts edits.
+- **Background reasoning** for the daily briefing (scheduled and on request)
+ and the weekly and monthly narratives; status cards never reason.
+
+### Fixed
+
+- **Dose-change screen.** The outbound screen blocks the dose-change class
+ across all seven languages, including the person's own medication names,
+ drug classes, spelled-out amounts and "extra / one more"; plan texts go
+ through the same screen.
+- **Glucose by time of day.** The usual-range line, the MCP baseline and the
+ signals of the day compare glucose with the same hours of earlier days
+ instead of setting a morning reading against whole-day means.
+- **Usual-range line** names the date of a latest reading that is not from
+ today, and a steady value as one figure.
+- **Memory refresh** asks the Coach capability before deferring, so a
+ switched-off Coach does not re-queue it.
+- **Dependencies.** The MCP SDK moves to 1.32 (GHSA-6qxp-vccf-f47h) and
+ `proxy-addr` to 2.0.8 (GHSA-jqcg-44mw-7w3h).
+
+### Changed
+
+- **Answer voice.** The first sentence answers; 40 to 120 words; at most one
+ next step.
+- **Keyboard focus** is drawn in a neutral tone everywhere.
+
+### Migrations
+
+- `0376_ai_reasoning_controls`, `0377_coach_message_trail`,
+ `0378_coach_fact_source`: additive columns with constant defaults or
+ nullable; no table rewrite.
+
## [1.40.6] — 2026-10-05
The Coach reads long series again; "today" only from today's readings; the
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 41a985a94..650820e4d 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.40.6
+ version: 1.41.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -9429,6 +9429,49 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
+ /api/insights/chat/{id}/messages/{messageId}/trail:
+ get:
+ tags:
+ - Insights
+ summary: Read the trail text of one Coach message
+ description: "v1.41 — the text of one assistant message's trail: the screened reasoning titles and summaries by trail
+ entry (model-written), the fact texts the turn recalled, and a pending fact proposal (the person's own data).
+ The trail's structure (phases, statuses, counts) is plaintext on the message's `metricSource.activity`; this
+ route serves only the text, decrypted for the account that owns the conversation. Fetch lazily when the person
+ opens the trail. A mixed read: while the Coach's text may not be shown for the record, `entries` is empty and
+ the `ai` state rides beside it, but `recalled` and `proposal` are still served, since they are the person's
+ data. While the medications module is off, recalled facts and a proposal that concern a medication are left out.
+ `trail` is null when the message has no trail or nothing in it may be served. A foreign or unknown conversation
+ or message id maps to 404 (never 403). Auth via cookie or a full-access Bearer token."
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Conversation id.
+ - name: messageId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Assistant message id within the conversation.
+ responses:
+ "200":
+ description: The trail text, or null when there is none or nothing in it may be served.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachMessageTrailResponse"
+ "401": *a1
+ "404":
+ description: Conversation or message not found, or not owned by the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
/api/insights/chat/{id}:
get:
tags:
@@ -9768,11 +9811,12 @@ paths:
tags:
- Insights
summary: List the caller's durable Coach facts
- description: "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then
- newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Never AI-gated: the
- facts stay readable and erasable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the
- owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing
- the read."
+ description: "v1.11.1 — returns the active facts the Coach keeps about the caller (highest-confidence then newest
+ first), each decrypted on the fly. The GDPR 'what do you know about me' surface. v1.41 — each fact carries its
+ source, the message it came from and when it was last used in a turn; a health fact still waiting for the
+ caller's confirmation is not listed. Never AI-gated: the facts stay readable and erasable while the Coach is
+ unavailable for any reason. Auth via cookie or Bearer; the owner is always narrowed from the session, never the
+ body. Undecryptable rows are omitted rather than failing the read."
responses:
"200":
description: The caller's active facts.
@@ -9783,6 +9827,51 @@ paths:
"401": *a1
"422": *a4
"429": *a5
+ post:
+ tags:
+ - Insights
+ summary: Remember one of the caller's own Coach messages
+ description: "v1.41 — the remember button. Saves the named user message as a fact with `source: \"user\"`: the text is
+ the message the server stored, filed as `medication` or `condition` when it names one, `context` otherwise. The
+ tap is the caller's confirmation, so a health message is saved too. A message the caller does not own, or an
+ assistant message, is a 404. Saving the same fact again returns the existing one with `created: false`. Gated on
+ the Coach module, never on AI. Auth via cookie or Bearer."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachFactCreateRequest"
+ responses:
+ "200":
+ description: The caller's memory already held this fact.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachFactAlreadyKnown"
+ "201":
+ description: The fact was saved.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachFactCreated"
+ "401": *a1
+ "403":
+ description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` =
+ `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a
+ valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.'
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "404":
+ description: Message not found or not one of the caller's own.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
delete:
tags:
- Insights
@@ -9800,6 +9889,44 @@ paths:
"422": *a4
"429": *a5
/api/insights/coach/facts/{id}:
+ patch:
+ tags:
+ - Insights
+ summary: Edit one Coach fact's wording
+ description: "v1.41 — rewrites the text of a fact the caller owns (re-encrypted at rest) and files it by its new
+ wording: text that names a medication moves to `medication`, so the medications module keeps filtering it; other
+ health wording moves a non-health category to `condition`; otherwise the category stays. A health fact the
+ caller edited counts as confirmed (`source: \"user\"`). A proposal still waiting for the caller's confirmation,
+ an unknown, cross-user or deleted id are all a 404. Never AI-gated. Auth via cookie or Bearer."
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Fact id.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachFactPatchRequest"
+ responses:
+ "200":
+ description: The fact after the edit.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachFactUpdated"
+ "401": *a1
+ "404":
+ description: Fact not found or not owned by the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
delete:
tags:
- Insights
@@ -25979,6 +26106,16 @@ components:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
+ reasoning:
+ description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means
+ `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET
+ /api/auth/me` as `coachReasoning`."
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
baseUpdatedAt:
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
@@ -26666,6 +26803,40 @@ components:
maxLength: 8
required:
- messageId
+ memoryDecision:
+ type: object
+ properties:
+ messageId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ proposalId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ accept:
+ type: boolean
+ required:
+ - messageId
+ - proposalId
+ - accept
+ planDecision:
+ type: object
+ properties:
+ messageId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ planId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ accept:
+ type: boolean
+ required:
+ - messageId
+ - planId
+ - accept
required:
- message
description: "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a
@@ -26863,6 +27034,31 @@ components:
- version
description: PUT body for the Insights layout — the layout fields plus the optional optimistic-concurrency base token
(`baseUpdatedAt`). Omit the token for the legacy unconditional write.
+ CoachFactCreateRequest:
+ type: object
+ properties:
+ messageId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ required:
+ - messageId
+ additionalProperties: false
+ description: "v1.41 — the remember button: the id of one of the caller's own Coach messages. Never the text: the server
+ reads the message it stored. Strict: unknown keys 422."
+ CoachFactPatchRequest:
+ type: object
+ properties:
+ fact:
+ type: string
+ minLength: 3
+ maxLength: 160
+ required:
+ - fact
+ additionalProperties: false
+ description: "v1.41 — the fact's new wording (3..160 characters). Only the text: the server reads the new wording to
+ file it (a fact that names a medication moves to `medication`, other health wording moves a non-health fact to
+ `condition`) and a health fact the caller wrote counts as confirmed. Strict: unknown keys 422."
UpdateEmergencyProfileRequest:
type: object
properties:
@@ -34972,6 +35168,14 @@ components:
type: boolean
assistantDocumentAiEnabled:
type: boolean
+ aiReasoningEnabled:
+ type: boolean
+ aiReasoningMaxEffort:
+ type: string
+ enum:
+ - low
+ - medium
+ - high
moduleAvailabilityJson:
anyOf:
- {}
@@ -38866,6 +39070,15 @@ components:
anyOf:
- type: string
- type: "null"
+ trailEncrypted:
+ anyOf:
+ - type: string
+ minLength: 1
+ - type: "null"
+ trailJson:
+ anyOf:
+ - type: string
+ - type: "null"
createdAt:
type: string
minLength: 1
@@ -39032,6 +39245,19 @@ components:
anyOf:
- type: string
- type: "null"
+ source:
+ type: string
+ minLength: 1
+ maxLength: 32
+ lastUsedAt:
+ anyOf:
+ - type: string
+ minLength: 1
+ - type: "null"
+ sourceMessageId:
+ anyOf:
+ - type: string
+ - type: "null"
createdAt:
type: string
minLength: 1
@@ -48884,6 +49110,16 @@ components:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
+ reasoning:
+ description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means
+ `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET
+ /api/auth/me` as `coachReasoning`."
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
updatedAt:
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
`baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or
@@ -52165,6 +52401,12 @@ components:
type: string
usage:
$ref: "#/components/schemas/CoachUsage"
+ stop:
+ $ref: "#/components/schemas/CoachStop"
+ withheldResults:
+ description: v1.41 — the turn was blocked after interim tables went out; remove them.
+ type: boolean
+ const: true
required:
- type
- conversationId
@@ -52212,6 +52454,10 @@ components:
const: result
result:
$ref: "#/components/schemas/CoachResultTable"
+ interim:
+ description: v1.41 — sent while the turn still runs.
+ type: boolean
+ const: true
required:
- type
- result
@@ -52240,9 +52486,43 @@ components:
- type
- clarification
additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: activity
+ activity:
+ $ref: "#/components/schemas/CoachActivity"
+ required:
+ - type
+ - activity
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: memoryNote
+ note:
+ $ref: "#/components/schemas/CoachMemoryNote"
+ required:
+ - type
+ - note
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: planProposal
+ proposal:
+ $ref: "#/components/schemas/CoachPlanProposal"
+ required:
+ - type
+ - proposal
+ additionalProperties: false
description: "One Server-Sent Events frame of a Coach turn, sent as `data: \\n\\n` and dispatched on `type`.
- Order: `step*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `clarification?` →
- `followUps?` → `done`, or a single `error`. Clients ignore a `type` they do not know."
+ Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` →
+ `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames
+ with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know."
type: object
CoachProvenance:
type: object
@@ -52451,6 +52731,25 @@ components:
that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise."
type: string
maxLength: 64
+ activity:
+ description: v1.41 — the turn's trail, metadata only.
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachActivityMeta"
+ stop:
+ description: v1.41 — why the answer was forced, when it was.
+ $ref: "#/components/schemas/CoachStop"
+ assumptions:
+ description: v1.41 — what the answer assumed instead of asking, at most two.
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachAssumption"
+ memoryNote:
+ description: v1.41 — the fact this reply saved or proposes, without its text.
+ $ref: "#/components/schemas/CoachMemoryNoteMeta"
+ planProposal:
+ description: v1.41 — the plan this reply proposes, without its text.
+ $ref: "#/components/schemas/CoachPlanProposalMeta"
required:
- windows
- metrics
@@ -52530,7 +52829,7 @@ components:
id:
type: string
pattern: ^s[1-9]\d{0,2}$
- description: "`s1`..`s12`, unique within a turn."
+ description: "`s1`..`s48`, unique within a turn."
tool:
type: string
enum:
@@ -52598,7 +52897,7 @@ components:
resultRef:
description: The table this step produced (`r1`..), when it produced one.
type: string
- pattern: ^r[1-6]$
+ pattern: ^r[1-8]$
required:
- id
- tool
@@ -52724,8 +53023,8 @@ components:
properties:
ref:
type: string
- pattern: ^r[1-6]$
- description: "`r1`..`r6`, unique within a message."
+ pattern: ^r[1-8]$
+ description: "`r1`..`r8`, unique within a message."
source:
type: object
properties:
@@ -52791,6 +53090,7 @@ components:
- type: string
enum:
- line
+ - compare
- bar
- histogram
- type: "null"
@@ -52807,7 +53107,7 @@ components:
pattern: ^[A-Za-z0-9_-]{1,64}$
ref:
type: string
- pattern: ^r[1-6]$
+ pattern: ^r[1-8]$
required:
- messageId
- ref
@@ -52846,6 +53146,7 @@ components:
- as_table
- related_metric
- continue
+ - change_assumption
labelKey:
type: string
label:
@@ -52856,7 +53157,7 @@ components:
properties:
ref:
type: string
- pattern: ^(?:r[1-6])?$
+ pattern: ^(?:r[1-8])?$
domain:
$ref: "#/components/schemas/CoachStepDomain"
window:
@@ -52891,6 +53192,91 @@ components:
enum:
- server
- model
+ assumption:
+ description: "v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks."
+ type: object
+ properties:
+ kind:
+ type: string
+ enum:
+ - metric
+ - window
+ - comparison
+ value:
+ type: object
+ properties:
+ metric:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ comparison:
+ description: v1.41 — what a comparison is drawn against.
+ type: string
+ enum:
+ - previous_period
+ - year_ago
+ - baseline_90d
+ goal:
+ description: v1.41 — an active plan or a goal fact, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ anchor:
+ description: v1.41 — a medication start, an illness episode or a cycle phase, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ additionalProperties: false
+ description: "What a choice stands for: catalog tokens and ids, never text."
+ required:
+ - kind
+ - value
+ additionalProperties: false
required:
- id
- kind
@@ -52909,7 +53295,11 @@ components:
enum:
- metric
- window
+ - comparison
+ - goal
+ - anchor
- context
+ description: "`comparison`, `goal` and `anchor` since v1.41."
choices:
type: array
items:
@@ -52976,7 +53366,23 @@ components:
- last90days
- lastYear
- allTime
+ comparison:
+ description: v1.41 — what a comparison is drawn against.
+ type: string
+ enum:
+ - previous_period
+ - year_ago
+ - baseline_90d
+ goal:
+ description: v1.41 — an active plan or a goal fact, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ anchor:
+ description: v1.41 — a medication start, an illness episode or a cycle phase, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
additionalProperties: false
+ description: "What a choice stands for: catalog tokens and ids, never text."
required:
- id
- labelKey
@@ -52987,6 +53393,11 @@ components:
freeText:
type: boolean
description: Whether a typed answer is welcome.
+ assumption:
+ description: v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first
+ choice.
+ type: string
+ pattern: ^c[1-4]$
required:
- kind
- choices
@@ -52994,6 +53405,328 @@ components:
additionalProperties: false
description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with
`clarification: { messageId, choiceId? }`."
+ CoachActivityMeta:
+ type: object
+ properties:
+ id:
+ type: string
+ pattern: ^a[1-9]\d?$
+ description: "`a1`..`a99`, unique within a turn."
+ phase:
+ type: string
+ enum:
+ - thinking
+ - memory
+ - fetch
+ - digest
+ - checkpoint
+ - remember
+ - plan
+ - asking
+ - stop
+ - answer
+ status:
+ type: string
+ enum:
+ - running
+ - done
+ - empty
+ - failed
+ round:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: The tool round, from 1.
+ labelKey:
+ type: string
+ description: Closed catalog key (`insights.coach.activity.*`).
+ label:
+ type: string
+ description: Rendered on the server in the request locale from the catalog; never model text.
+ stepRef:
+ description: The `step` a `fetch` entry stands for.
+ type: string
+ pattern: ^s[1-9]\d{0,2}$
+ count:
+ description: Readings, lookups or entries the server counted.
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ durationMs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ stop:
+ description: Set on the `stop` entry.
+ type: string
+ enum:
+ - budget
+ - time
+ - cap
+ - no_progress
+ required:
+ - id
+ - phase
+ - status
+ - round
+ - labelKey
+ - label
+ additionalProperties: false
+ description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog
+ label, counts. The model-written title and text are behind `GET
+ /api/insights/chat/{id}/messages/{messageId}/trail`."
+ CoachStop:
+ type: object
+ properties:
+ reason:
+ type: string
+ enum:
+ - budget
+ - time
+ - cap
+ - no_progress
+ rounds:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: The rounds the turn ran, the final one included.
+ required:
+ - reason
+ - rounds
+ additionalProperties: false
+ description: "v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round
+ cap, or no new data."
+ CoachAssumption:
+ type: object
+ properties:
+ kind:
+ type: string
+ enum:
+ - metric
+ - window
+ - comparison
+ value:
+ type: object
+ properties:
+ labelKey:
+ type: string
+ label:
+ type: string
+ value:
+ type: object
+ properties:
+ metric:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ comparison:
+ description: v1.41 — what a comparison is drawn against.
+ type: string
+ enum:
+ - previous_period
+ - year_ago
+ - baseline_90d
+ goal:
+ description: v1.41 — an active plan or a goal fact, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ anchor:
+ description: v1.41 — a medication start, an illness episode or a cycle phase, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ additionalProperties: false
+ description: "What a choice stands for: catalog tokens and ids, never text."
+ required:
+ - labelKey
+ - label
+ - value
+ additionalProperties: false
+ alternatives:
+ type: array
+ items:
+ type: object
+ properties:
+ labelKey:
+ type: string
+ label:
+ type: string
+ value:
+ type: object
+ properties:
+ metric:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ comparison:
+ description: v1.41 — what a comparison is drawn against.
+ type: string
+ enum:
+ - previous_period
+ - year_ago
+ - baseline_90d
+ goal:
+ description: v1.41 — an active plan or a goal fact, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ anchor:
+ description: v1.41 — a medication start, an illness episode or a cycle phase, by id.
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ additionalProperties: false
+ description: "What a choice stands for: catalog tokens and ids, never text."
+ required:
+ - labelKey
+ - label
+ - value
+ additionalProperties: false
+ description: At most 3.
+ required:
+ - kind
+ - value
+ - alternatives
+ additionalProperties: false
+ description: v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a
+ `change_assumption` follow-up.
+ CoachMemoryNoteMeta:
+ type: object
+ properties:
+ proposal:
+ type: boolean
+ description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes
+ `factId`."
+ proposalId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ factId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ required:
+ - proposal
+ - category
+ additionalProperties: false
+ description: v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text.
+ CoachPlanProposalMeta:
+ type: object
+ properties:
+ planId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ metric:
+ type: string
+ maxLength: 64
+ reviewInDays:
+ type: integer
+ minimum: 7
+ maximum: 56
+ required:
+ - planId
+ - metric
+ - reviewInDays
+ additionalProperties: false
+ description: v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text.
CoachUsage:
type: object
properties:
@@ -53028,8 +53761,8 @@ components:
properties:
ref:
type: string
- pattern: ^r[1-6]$
- description: "`r1`..`r6`, unique within a message."
+ pattern: ^r[1-8]$
+ description: "`r1`..`r8`, unique within a message."
source:
type: object
properties:
@@ -53095,6 +53828,7 @@ components:
- type: string
enum:
- line
+ - compare
- bar
- histogram
- type: "null"
@@ -53111,7 +53845,7 @@ components:
pattern: ^[A-Za-z0-9_-]{1,64}$
ref:
type: string
- pattern: ^r[1-6]$
+ pattern: ^r[1-8]$
required:
- messageId
- ref
@@ -53184,6 +53918,38 @@ components:
- x
- series
additionalProperties: false
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: compare
+ mode:
+ type: string
+ enum:
+ - periods
+ - metrics
+ x:
+ type: string
+ a:
+ type: string
+ b:
+ type: string
+ axes:
+ anyOf:
+ - type: number
+ const: 1
+ - type: number
+ const: 2
+ required:
+ - kind
+ - mode
+ - x
+ - a
+ - b
+ - axes
+ additionalProperties: false
+ description: "v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one
+ or two axes). `a` and `b` name the value columns, `x` the shared column."
- type: object
properties:
kind:
@@ -53257,6 +54023,143 @@ components:
additionalProperties: false
description: "A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns
the conversation."
+ CoachActivity:
+ type: object
+ properties:
+ id:
+ type: string
+ pattern: ^a[1-9]\d?$
+ description: "`a1`..`a99`, unique within a turn."
+ phase:
+ type: string
+ enum:
+ - thinking
+ - memory
+ - fetch
+ - digest
+ - checkpoint
+ - remember
+ - plan
+ - asking
+ - stop
+ - answer
+ status:
+ type: string
+ enum:
+ - running
+ - done
+ - empty
+ - failed
+ round:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: The tool round, from 1.
+ labelKey:
+ type: string
+ description: Closed catalog key (`insights.coach.activity.*`).
+ label:
+ type: string
+ description: Rendered on the server in the request locale from the catalog; never model text.
+ stepRef:
+ description: The `step` a `fetch` entry stands for.
+ type: string
+ pattern: ^s[1-9]\d{0,2}$
+ count:
+ description: Readings, lookups or entries the server counted.
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ durationMs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ stop:
+ description: Set on the `stop` entry.
+ type: string
+ enum:
+ - budget
+ - time
+ - cap
+ - no_progress
+ title:
+ description: A screened reasoning title or checkpoint sentence.
+ type: string
+ maxLength: 80
+ text:
+ description: A screened reasoning summary for the round.
+ type: string
+ maxLength: 400
+ required:
+ - id
+ - phase
+ - status
+ - round
+ - labelKey
+ - label
+ additionalProperties: false
+ description: v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are
+ model text, screened, and sent only to the account that owns the conversation.
+ CoachMemoryNote:
+ type: object
+ properties:
+ proposal:
+ type: boolean
+ description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes
+ `factId`."
+ proposalId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ factId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ fact:
+ type: string
+ maxLength: 160
+ required:
+ - proposal
+ - category
+ - fact
+ additionalProperties: false
+ description: v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a
+ tap). Owner only.
+ CoachPlanProposal:
+ type: object
+ properties:
+ planId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ metric:
+ type: string
+ maxLength: 64
+ reviewInDays:
+ type: integer
+ minimum: 7
+ maximum: 56
+ ifCue:
+ type: string
+ thenAction:
+ type: string
+ target:
+ type: string
+ required:
+ - planId
+ - metric
+ - reviewInDays
+ - ifCue
+ - thenAction
+ additionalProperties: false
+ description: "v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId,
+ accept }`. Owner only."
CoachMessageResultsResponse:
type: object
properties:
@@ -53289,7 +54192,7 @@ components:
properties:
ref:
type: string
- pattern: ^r[1-6]$
+ pattern: ^r[1-8]$
withheld:
type: string
enum:
@@ -53302,6 +54205,88 @@ components:
- withheld
additionalProperties: false
description: "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why."
+ CoachMessageTrailResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ trail:
+ anyOf:
+ - $ref: "#/components/schemas/CoachTrail"
+ - type: "null"
+ ai:
+ description: The `coach` capability. While unavailable `trail.entries` is empty.
+ $ref: "#/components/schemas/AiCapabilityState"
+ required:
+ - trail
+ - ai
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ CoachTrail:
+ type: object
+ properties:
+ entries:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ pattern: ^a[1-9]\d?$
+ title:
+ type: string
+ maxLength: 80
+ text:
+ type: string
+ maxLength: 400
+ required:
+ - id
+ additionalProperties: false
+ recalled:
+ type: array
+ items:
+ type: string
+ maxLength: 160
+ proposal:
+ type: object
+ properties:
+ proposalId:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,64}$
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ fact:
+ type: string
+ maxLength: 160
+ required:
+ - proposalId
+ - category
+ - fact
+ additionalProperties: false
+ required:
+ - entries
+ additionalProperties: false
+ description: v1.41 — the model-written text of a turn's trail and the fact texts it touched. Encrypted at rest and
+ served only to the account that owns the conversation.
CoachConversationDetailResponse:
type: object
properties:
@@ -53744,11 +54729,12 @@ components:
type: string
enum:
- preference
- - condition
- goal
- - constraint
- context
- description: "App-side closed category: preference | condition | goal | constraint | context."
+ - condition
+ - constraint
+ - medication
+ description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)."
text:
type: string
description: Decrypted fact text.
@@ -53757,16 +54743,54 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: 0..100 server-assigned extraction confidence.
+ source:
+ type: string
+ enum:
+ - user
+ - coach
+ - extracted
+ - pattern
+ - proposed
+ description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed),
+ `coach` (saved during a turn), `extracted` (the background extraction; every fact older than
+ v1.41), `pattern` (the deterministic matcher). `proposed` is never listed."
+ sourceConversationId:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: The conversation the fact came out of, when known.
+ sourceMessageId:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: v1.41 — the message the fact came out of, when known.
+ lastUsedAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ description: v1.41 — when the fact last went into a Coach turn.
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ updatedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ description: v1.41 — when the fact was last written or edited.
required:
- id
- category
- text
- confidence
+ - source
+ - sourceConversationId
+ - sourceMessageId
+ - lastUsedAt
- createdAt
+ - updatedAt
additionalProperties: false
description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.
required:
@@ -53784,6 +54808,128 @@ components:
- data
- error
additionalProperties: false
+ CoachFactAlreadyKnown:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ fact:
+ type: object
+ properties:
+ id:
+ type: string
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)."
+ text:
+ type: string
+ description: The fact as stored.
+ source:
+ type: string
+ enum:
+ - user
+ - coach
+ - extracted
+ - pattern
+ - proposed
+ description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed),
+ `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41),
+ `pattern` (the deterministic matcher). `proposed` is never listed."
+ required:
+ - id
+ - category
+ - text
+ - source
+ additionalProperties: false
+ created:
+ type: boolean
+ description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the
+ existing fact is returned.
+ required:
+ - fact
+ - created
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ CoachFactCreated:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ fact:
+ type: object
+ properties:
+ id:
+ type: string
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)."
+ text:
+ type: string
+ description: The fact as stored.
+ source:
+ type: string
+ enum:
+ - user
+ - coach
+ - extracted
+ - pattern
+ - proposed
+ description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed),
+ `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41),
+ `pattern` (the deterministic matcher). `proposed` is never listed."
+ required:
+ - id
+ - category
+ - text
+ - source
+ additionalProperties: false
+ created:
+ type: boolean
+ description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the
+ existing fact is returned.
+ required:
+ - fact
+ - created
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
CoachFactsCleared:
type: object
properties:
@@ -53810,6 +54956,67 @@ components:
- data
- error
additionalProperties: false
+ CoachFactUpdated:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ fact:
+ type: object
+ properties:
+ id:
+ type: string
+ category:
+ type: string
+ enum:
+ - preference
+ - goal
+ - context
+ - condition
+ - constraint
+ - medication
+ description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)."
+ text:
+ type: string
+ description: The fact as stored.
+ source:
+ type: string
+ enum:
+ - user
+ - coach
+ - extracted
+ - pattern
+ - proposed
+ description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed),
+ `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41),
+ `pattern` (the deterministic matcher). `proposed` is never listed."
+ updatedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ required:
+ - id
+ - category
+ - text
+ - source
+ - updatedAt
+ additionalProperties: false
+ required:
+ - fact
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
CoachFactDeleted:
type: object
properties:
@@ -58213,7 +59420,16 @@ components:
description: Robust upper edge.
latest:
type: number
- description: Today's latest reading, same units.
+ description: The latest reading, same units. Today's only when `latestIsToday` is true; otherwise it is from
+ `latestDate`.
+ latestDate:
+ type: string
+ pattern: ^\d{4}-\d{2}-\d{2}$
+ description: Calendar day of the latest reading in the account's time zone, `YYYY-MM-DD`.
+ latestIsToday:
+ type: boolean
+ description: True when `latestDate` is today in the account's time zone. When false, a client names the reading's date
+ instead of calling it today's.
placement:
type: string
enum:
@@ -58225,10 +59441,19 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Distinct days behind the band, for transparency.
+ basis:
+ description: Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose).
+ `latest` is then today's mean so far and `low` / `high` the usual range for the same hours of the
+ earlier days, not the whole-day band; a client naming the range says it is the one for this time of
+ day. Absent for the whole-day comparison.
+ type: string
+ const: sameHours
required:
- low
- high
- latest
+ - latestDate
+ - latestIsToday
- placement
- sampleDays
additionalProperties: false
@@ -73649,6 +74874,8 @@ components:
$ref: "#/components/schemas/ModuleAccessMap"
ai:
$ref: "#/components/schemas/AiAccountBlock"
+ coachReasoning:
+ $ref: "#/components/schemas/CoachReasoningState"
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
@@ -73675,6 +74902,7 @@ components:
- accountAccess
- moduleAccess
- ai
+ - coachReasoning
- recordSession
- notificationPrefs
- onboarding
@@ -73689,7 +74917,9 @@ components:
carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is
not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the
`modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record,
- with the same masking; it replaces reading `GET /api/feature-flags`."
+ with the same masking; it replaces reading `GET /api/feature-flags`. `coachReasoning` (v1.41) is the Coach's
+ resolved thinking depth: the caller's own preference against the operator's reasoning controls and the record's
+ provider."
AccountAccess:
type: object
properties:
@@ -74182,6 +75412,67 @@ components:
- responseTimeoutMs
additionalProperties: false
description: The account-level companion to the capability map.
+ CoachReasoningState:
+ type: object
+ properties:
+ level:
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
+ description: "The level a Coach turn runs at right now, every cap applied: the person's choice, lowered to the
+ operator's highest level, to `medium` when the operator pays for the provider, and `off` when the operator
+ switched reasoning off or the provider cannot reason."
+ preference:
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
+ description: What the person picked (`reasoning` on `PUT /api/auth/me/coach-prefs`), `medium` when they never chose.
+ Bind the settings field to this, not to `level`.
+ maxLevel:
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
+ description: The highest level the settings may offer. Options above it are shown disabled as limited by the operator.
+ `off` when the operator switched reasoning off.
+ available:
+ type: boolean
+ description: False when the operator switched reasoning off or the configured provider cannot reason; the settings field
+ is then locked.
+ offIsReal:
+ type: boolean
+ description: False when the provider cannot switch reasoning off (it thinks at its lowest level instead); label the
+ lowest option "Minimal" rather than "Off".
+ source:
+ type: string
+ enum:
+ - user
+ - job
+ - admin_off
+ - admin_cap
+ - cost_cap
+ - unsupported
+ description: "Which layer decided `level`: the person's own choice (`user`), the operator's switch (`admin_off`) or cap
+ (`admin_cap`), the operator-funded ceiling (`cost_cap`), or a provider that cannot reason (`unsupported`).
+ `job` never appears here."
+ required:
+ - level
+ - preference
+ - maxLevel
+ - available
+ - offIsReal
+ - source
+ additionalProperties: false
+ description: The Coach's thinking depth for the signed-in person, resolved on the server from their preference, the
+ operator's reasoning switch and cap, who pays for the provider, and what the provider can do.
RecordSessionState:
type: object
properties:
@@ -78175,6 +79466,16 @@ components:
description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false`
switches them off.
type: boolean
+ reasoning:
+ description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means
+ `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET
+ /api/auth/me` as `coachReasoning`."
+ type: string
+ enum:
+ - off
+ - low
+ - medium
+ - high
required:
- tone
- verbosity
diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts
new file mode 100644
index 000000000..259a8b2f6
--- /dev/null
+++ b/e2e/coach-activity.spec.ts
@@ -0,0 +1,488 @@
+import AxeBuilder from "@axe-core/playwright";
+import type { Page } from "@playwright/test";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+import {
+ FIXED_NOW,
+ done,
+ end,
+ feed,
+ installLiveCoachStream,
+ posts,
+ serveConversation,
+ tokens,
+ applyTheme,
+ type StubConversation,
+} from "./setup/coach-live-stream";
+
+/**
+ * The live trail of a Coach turn: one quiet status line at the top of the
+ * answer.
+ *
+ * Contracts under test, at 1440 and 390 px, light and dark:
+ *
+ * 1. While the turn runs the line says what is happening right now and
+ * moves with the frames: Thinking… → Fetching blood pressure… →
+ * Summarising 214 readings… → Writing the answer….
+ * 2. A table the turn has read shows as a one-line preview before the
+ * first token.
+ * 3. Nothing opens by itself: the trail stays closed (`aria-expanded`
+ * false) in every state, after the answer and after a reload.
+ * 4. After the answer the line is one summary ("Thought it through, 1
+ * lookup, 7 s"); the preview gives way to the answer's chart.
+ * 5. A tap opens the trail; the reasoning text of a round sits behind its
+ * own closed disclosure.
+ * 6. No horizontal scroll, a 44 px tap target on phones, axe clean.
+ *
+ * The chat stream is fed frame by frame from the spec
+ * (`installLiveCoachStream`), so every live state is observed rather than
+ * flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each
+ * state.
+ */
+
+const CONVERSATION_ID = "coach-activity-e2e";
+const QUESTION = "How was my blood pressure this month?";
+const ANSWER =
+ "Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range.";
+
+const BP_VALUES: Array<[number, number]> = [
+ [128, 84],
+ [125, 82],
+ [131, 85],
+ [122, 80],
+ [124, 81],
+ [127, 83],
+ [121, 79],
+ [126, 82],
+ [123, 80],
+ [126, 81],
+];
+
+const BP_TABLE = {
+ ref: "r1",
+ source: {
+ tool: "get_metric_table",
+ domain: "bp",
+ window: "last30days",
+ period: "current",
+ granularity: "day",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.byDay",
+ title: "Blood pressure by day",
+ rowCount: BP_VALUES.length,
+ chartKind: "line",
+ displayed: false,
+ columns: [
+ {
+ key: "day",
+ kind: "period",
+ labelKey: "coach.result.column.day",
+ label: "Day",
+ },
+ {
+ key: "systolic",
+ kind: "number",
+ labelKey: "coach.result.column.systolic",
+ label: "Systolic",
+ unit: "mmHg",
+ decimals: 0,
+ },
+ {
+ key: "diastolic",
+ kind: "number",
+ labelKey: "coach.result.column.diastolic",
+ label: "Diastolic",
+ unit: "mmHg",
+ decimals: 0,
+ },
+ {
+ key: "readings",
+ kind: "count",
+ labelKey: "coach.result.column.readings",
+ label: "Readings",
+ },
+ ],
+ rows: BP_VALUES.map(([sys, dia], i) => [
+ new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10),
+ sys,
+ dia,
+ i === 0 ? 205 : 1,
+ ]),
+ truncated: false,
+ chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
+};
+const BP_SHOWN = { ...BP_TABLE, displayed: true };
+const BP_META = {
+ ref: "r1",
+ source: BP_TABLE.source,
+ shape: BP_TABLE.shape,
+ titleKey: BP_TABLE.titleKey,
+ title: BP_TABLE.title,
+ rowCount: BP_TABLE.rowCount,
+ chartKind: "line",
+ displayed: true,
+};
+
+const STEP = {
+ id: "s1",
+ tool: "get_metric_table",
+ labelKey: "coach.step.readWindow",
+ label: "Checking: Blood pressure, last 30 days",
+ domain: "bp",
+ window: "last30days",
+ granularity: "day",
+};
+
+function entry(
+ id: string,
+ phase: string,
+ status: string,
+ label: string,
+ extra: Record = {},
+) {
+ return {
+ type: "activity",
+ activity: {
+ id,
+ phase,
+ status,
+ round: phase === "answer" ? 2 : 1,
+ labelKey: `insights.coach.activity.${phase}`,
+ label,
+ ...extra,
+ },
+ };
+}
+
+const THINK_TITLE = "Looking at the whole month";
+const THINK_TEXT =
+ "The month has daily readings; a daily table answers this best.";
+
+const ACTIVITY_META = [
+ {
+ id: "a1",
+ phase: "thinking",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.thinking",
+ label: "Thinking…",
+ durationMs: 2_400,
+ },
+ {
+ id: "a2",
+ phase: "fetch",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.fetching",
+ label: "Fetching blood pressure, last 30 days…",
+ stepRef: "s1",
+ count: 214,
+ durationMs: 900,
+ },
+ {
+ id: "a3",
+ phase: "digest",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.digestDone",
+ label: "214 readings from 1 area",
+ count: 214,
+ durationMs: 400,
+ },
+ {
+ id: "a4",
+ phase: "answer",
+ status: "done",
+ round: 2,
+ labelKey: "insights.coach.activity.answer",
+ label: "Writing the answer…",
+ durationMs: 3_000,
+ },
+];
+
+const METRIC_SOURCE = {
+ windows: ["last30days"],
+ metrics: ["bp"],
+ counts: { bp: 214 },
+ steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }],
+ activity: ACTIVITY_META,
+ results: [BP_META],
+ method: {
+ entries: [
+ {
+ domain: "bp",
+ window: "last30days",
+ granularity: "day",
+ count: 214,
+ aggregation: "mean",
+ },
+ ],
+ text: "Blood pressure, last 30 days, 214 readings, daily averages.",
+ },
+};
+
+function conversation(): StubConversation {
+ return {
+ id: CONVERSATION_ID,
+ title: "Blood pressure this month",
+ messages: [],
+ results: { "coach-activity-e2e-a1": [BP_SHOWN] },
+ trails: {
+ "coach-activity-e2e-a1": {
+ entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }],
+ },
+ },
+ };
+}
+
+const VIEWPORTS = [
+ { name: "1440", width: 1440, height: 900 },
+ { name: "390", width: 390, height: 844 },
+] as const;
+
+const SHOTS = process.env.COACH_SCREENSHOTS_DIR;
+
+async function shot(page: Page, name: string) {
+ if (!SHOTS) return;
+ mkdirSync(SHOTS, { recursive: true });
+ await page.screenshot({ path: join(SHOTS, `${name}.png`) });
+}
+
+async function noHorizontalScroll(page: Page) {
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - window.innerWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(0);
+}
+
+test.describe("Coach live trail", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ for (const viewport of VIEWPORTS) {
+ for (const theme of ["light", "dark"] as const) {
+ test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "sets its own viewports; one project suffices",
+ );
+ const label = `${viewport.name}-${theme}`;
+ await page.setViewportSize(viewport);
+ await page.clock.setFixedTime(FIXED_NOW);
+ await serveAiBlock(page, aiBlockAvailable());
+ const stub = conversation();
+ await serveConversation(page, stub);
+ await installLiveCoachStream(page);
+ await applyTheme(page, theme);
+
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+ await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
+ await page.locator('[data-slot="coach-input-send"]').click();
+
+ const turn = page.locator(
+ '[role="log"] [data-slot="coach-bubble-assistant"]',
+ );
+ const line = turn.locator('[data-slot="coach-turn-steps"]');
+ const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]');
+ const current = line.locator('[data-slot="coach-turn-steps-active"]');
+
+ // 1. Before any frame: Thinking…, closed.
+ await expect(line).toHaveAttribute("data-state", "running");
+ await expect(current).toHaveText("Thinking…");
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+
+ await feed(page, [entry("a1", "thinking", "running", "Thinking…")]);
+ await feed(page, [
+ entry("a1", "thinking", "running", "Thinking…", {
+ title: THINK_TITLE,
+ }),
+ ]);
+ await expect(current).toHaveText(THINK_TITLE);
+
+ await feed(page, [
+ entry("a1", "thinking", "done", "Thinking…", {
+ title: THINK_TITLE,
+ text: THINK_TEXT,
+ durationMs: 2_400,
+ }),
+ { type: "step", step: { ...STEP, status: "running" } },
+ entry(
+ "a2",
+ "fetch",
+ "running",
+ "Fetching blood pressure, last 30 days…",
+ {
+ stepRef: "s1",
+ },
+ ),
+ ]);
+ await expect(current).toHaveText(
+ "Fetching blood pressure, last 30 days…",
+ );
+
+ // 2. The table it read previews before the first token.
+ await feed(page, [
+ { type: "result", result: BP_TABLE, interim: true },
+ { type: "step", step: { ...STEP, status: "done", count: 214 } },
+ entry(
+ "a2",
+ "fetch",
+ "done",
+ "Fetching blood pressure, last 30 days…",
+ {
+ stepRef: "s1",
+ count: 214,
+ durationMs: 900,
+ },
+ ),
+ entry("a3", "digest", "running", "Summarising 214 readings…", {
+ count: 214,
+ }),
+ ]);
+ await expect(current).toHaveText("Summarising 214 readings…");
+ const preview = turn.locator(
+ '[data-slot="coach-interim-result"][data-ref="r1"]',
+ );
+ await expect(preview).toBeVisible();
+ await expect(preview).toContainText("Blood pressure by day");
+ await expect(
+ turn.locator('[data-slot="coach-answer-bubble"]'),
+ ).toHaveCount(0);
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await noHorizontalScroll(page);
+ await shot(page, `${label}-1-running`);
+
+ await feed(page, [
+ entry("a3", "digest", "done", "214 readings from 1 area", {
+ count: 214,
+ durationMs: 400,
+ }),
+ entry("a4", "answer", "running", "Writing the answer…"),
+ ...tokens(ANSWER),
+ ]);
+ await expect(current).toHaveText("Writing the answer…");
+ await expect(
+ turn.locator('[data-slot="coach-answer-bubble"]'),
+ ).toContainText(ANSWER);
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+
+ // The persisted copy lands with the refetch after `done`.
+ stub.messages.push(
+ { id: "coach-activity-e2e-u1", role: "user", content: QUESTION },
+ {
+ id: "coach-activity-e2e-a1",
+ role: "assistant",
+ content: ANSWER,
+ metricSource: METRIC_SOURCE,
+ },
+ );
+ await feed(page, [
+ entry("a4", "answer", "done", "Writing the answer…", {
+ durationMs: 3_000,
+ }),
+ { type: "provenance", metricSource: METRIC_SOURCE },
+ { type: "result", result: BP_SHOWN },
+ done(CONVERSATION_ID, "coach-activity-e2e-a1"),
+ ]);
+ await end(page);
+
+ // 4. One calm summary, still closed; the preview became the chart.
+ const bubble = page
+ .locator('[data-slot="coach-bubble-assistant"]')
+ .filter({ hasText: ANSWER });
+ const settled = bubble.locator('[data-slot="coach-turn-steps"]');
+ await expect(settled).toHaveAttribute("data-state", "done");
+ await expect(
+ settled.locator('[data-slot="coach-turn-steps-done"]'),
+ ).toHaveText("Thought it through, 1 lookup, 7 s");
+ const settledToggle = settled.locator(
+ '[data-slot="coach-turn-steps-toggle"]',
+ );
+ await expect(settledToggle).toHaveAttribute("aria-expanded", "false");
+ await expect(
+ bubble.locator('[data-slot="coach-interim-result"]'),
+ ).toHaveCount(0);
+ await expect(
+ bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'),
+ ).toBeVisible();
+ await expect(
+ bubble.locator('[data-slot="coach-result-chart-plot"]'),
+ ).toBeVisible();
+ expect((await posts(page))[0]).toMatchObject({ message: QUESTION });
+ await noHorizontalScroll(page);
+ await shot(page, `${label}-2-settled`);
+
+ // 6. A 44 px tap target on phones.
+ if (viewport.width < 640) {
+ const box = await settledToggle.boundingBox();
+ expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
+ }
+
+ // 5. A tap opens the trail; the round's reasoning stays behind its own
+ // closed disclosure.
+ await settledToggle.click();
+ await expect(settledToggle).toHaveAttribute("aria-expanded", "true");
+ const panel = settled.locator('[data-slot="coach-turn-steps-panel"]');
+ await expect(panel).toBeVisible();
+ const rows = panel.locator('[data-slot="coach-turn-step"]');
+ await expect(rows).toHaveCount(4);
+ await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking");
+ await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch");
+ await expect(
+ panel.locator('[data-slot="coach-method-line"]'),
+ ).toContainText(METRIC_SOURCE.method.text);
+ const detail = rows
+ .nth(0)
+ .locator('[data-slot="coach-turn-step-detail-toggle"]');
+ await expect(detail).toHaveAttribute("aria-expanded", "false");
+ await expect(panel).not.toContainText(THINK_TEXT);
+ await detail.click();
+ await expect(panel).toContainText(THINK_TEXT);
+ await noHorizontalScroll(page);
+ await shot(page, `${label}-3-open`);
+
+ const axe = await new AxeBuilder({ page })
+ .include('[data-slot="coach-bubble-assistant"]')
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
+ .analyze();
+ expect(
+ axe.violations.map((v) => ({
+ id: v.id,
+ nodes: v.nodes.map((n) => n.target.join(" ")),
+ })),
+ ).toEqual([]);
+
+ // 3. After a reload the trail is closed again, and opening it reads
+ // the stored titles and texts.
+ await page.goto(`/coach?c=${CONVERSATION_ID}`, {
+ waitUntil: "domcontentloaded",
+ });
+ const reloaded = page
+ .locator('[data-slot="coach-bubble-assistant"]')
+ .filter({ hasText: ANSWER })
+ .locator('[data-slot="coach-turn-steps"]');
+ await expect(reloaded).toHaveAttribute("data-state", "done");
+ await expect(
+ reloaded.locator('[data-slot="coach-turn-steps-done"]'),
+ ).toHaveText("Thought it through, 1 lookup, 7 s");
+ const reloadedToggle = reloaded.locator(
+ '[data-slot="coach-turn-steps-toggle"]',
+ );
+ await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false");
+ await expect(
+ reloaded.locator('[data-slot="coach-turn-steps-panel"]'),
+ ).toHaveCount(0);
+ await reloadedToggle.click();
+ await expect(
+ reloaded.locator('[data-slot="coach-turn-step"]').nth(0),
+ ).toContainText(THINK_TITLE);
+ });
+ }
+ }
+});
diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts
index fe13504f9..060ab06b2 100644
--- a/e2e/coach-answer-layout.spec.ts
+++ b/e2e/coach-answer-layout.spec.ts
@@ -18,9 +18,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* are not text on the row; the details icon shows them in a tooltip on
* keyboard focus (desktop) and on a tap (phone); Enter and Space keep it
* open, and a second tap or Escape closes it.
- * 2. The follow-up chips sit in the answer's column: their left edge is
- * the bubble's, their text is the answer's size, and a chip is at most
- * 36 px tall beside a pointer and at least 44 px on a phone.
+ * 2. The follow-up chips sit in the answer's column, right under the
+ * answer and above the action row (the row is the last thing in the
+ * column): their left edge is the bubble's, their text is the
+ * answer's size, and a chip is at most 36 px tall beside a pointer and
+ * at least 44 px on a phone.
* 3. Under a question, copy and remember share one row; nothing stacks
* below it.
* 4. Scrolled away from the end, a round button appears; it brings the
@@ -311,7 +313,14 @@ test.describe("Coach answer layout", () => {
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
const chipsBox = await box(chips);
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
- expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
+ // No empty band between the answer and its replies: the column's own
+ // gap, whatever the hover state of the row below.
+ const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height);
+ expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0);
+ expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12);
+ expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual(
+ chipsBox.y + chipsBox.height - 1,
+ );
const answerSize = await bubble.evaluate(
(el) => getComputedStyle(el).fontSize,
);
diff --git a/e2e/coach-clarification.spec.ts b/e2e/coach-clarification.spec.ts
new file mode 100644
index 000000000..a703bfc66
--- /dev/null
+++ b/e2e/coach-clarification.spec.ts
@@ -0,0 +1,261 @@
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+import {
+ FIXED_NOW,
+ done,
+ end,
+ feed,
+ installLiveCoachStream,
+ posts,
+ serveConversation,
+ tokens,
+ applyTheme,
+ type StubConversation,
+} from "./setup/coach-live-stream";
+
+/**
+ * A clarifying question is a conversation, not a form.
+ *
+ * 1. The question is a normal Coach message; its choices are quiet reply
+ * pills under it, the assumed one first. There is no card.
+ * 2. A pill sends its label as the person's own message, with
+ * `clarification: { messageId, choiceId }`.
+ * 3. A typed answer works too, with `clarification: { messageId }`.
+ * 4. The trail above the question stays closed.
+ */
+
+const CONVERSATION_ID = "coach-clarify-e2e";
+const QUESTION = "Is my heart rate okay?";
+const ASKS =
+ "Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
+const ANSWER =
+ "Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
+
+const CLARIFICATION = {
+ kind: "metric",
+ freeText: true,
+ assumption: "c1",
+ choices: [
+ {
+ id: "c1",
+ labelKey: "insights.coach.metric.resting_hr",
+ label: "Resting HR",
+ value: { metric: "resting_hr" },
+ },
+ {
+ id: "c2",
+ labelKey: "insights.coach.metric.walking_hr",
+ label: "Walking HR",
+ value: { metric: "walking_hr" },
+ },
+ ],
+};
+const ASK_SOURCE = {
+ windows: ["last30days"],
+ metrics: ["resting_hr"],
+ activity: [
+ {
+ id: "a1",
+ phase: "thinking",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.thinking",
+ label: "Thinking…",
+ durationMs: 1_200,
+ },
+ {
+ id: "a2",
+ phase: "asking",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.asking",
+ label: "Asking you…",
+ durationMs: 300,
+ },
+ ],
+ clarification: CLARIFICATION,
+};
+
+async function ask(
+ page: import("@playwright/test").Page,
+ stub: StubConversation,
+) {
+ await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
+ await page.locator('[data-slot="coach-input-send"]').click();
+ const live = page.locator(
+ '[role="log"] [data-slot="coach-turn-steps-active"]',
+ );
+ await feed(page, [
+ {
+ type: "activity",
+ activity: {
+ ...ASK_SOURCE.activity[0],
+ status: "running",
+ durationMs: undefined,
+ },
+ },
+ {
+ type: "activity",
+ activity: {
+ ...ASK_SOURCE.activity[1],
+ status: "running",
+ durationMs: undefined,
+ },
+ },
+ ]);
+ await expect(live).toHaveText("Asking you…");
+ stub.messages.push(
+ { id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
+ {
+ id: "coach-clarify-e2e-a1",
+ role: "assistant",
+ content: ASKS,
+ metricSource: ASK_SOURCE,
+ },
+ );
+ await feed(page, [
+ ...tokens(ASKS),
+ { type: "activity", activity: ASK_SOURCE.activity[1] },
+ { type: "provenance", metricSource: ASK_SOURCE },
+ { type: "clarification", clarification: CLARIFICATION },
+ done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
+ ]);
+ await end(page);
+}
+
+test.describe("Coach clarifying question", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test("pills in the conversation, the assumed one first, and a tap answers", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "the flow is viewport-independent; the activity spec covers 390 px",
+ );
+ await page.clock.setFixedTime(FIXED_NOW);
+ await serveAiBlock(page, aiBlockAvailable());
+ const stub: StubConversation = {
+ id: CONVERSATION_ID,
+ title: "Heart rate",
+ messages: [],
+ results: {},
+ trails: {},
+ };
+ await serveConversation(page, stub);
+ await installLiveCoachStream(page);
+ await applyTheme(page, "light");
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+
+ await ask(page, stub);
+
+ const asked = page
+ .locator('[data-slot="coach-bubble-assistant"]')
+ .filter({ hasText: ASKS });
+ await expect(asked).toHaveCount(1);
+ // 1. No card anywhere; the pills sit in the answer's own column.
+ await expect(
+ page.locator('[data-slot="coach-clarification-card"]'),
+ ).toHaveCount(0);
+ const pills = asked.locator('[data-slot="coach-suggested-replies"]');
+ await expect(pills).toBeVisible();
+ await expect(pills).toHaveAttribute(
+ "data-message-id",
+ "coach-clarify-e2e-a1",
+ );
+ const choices = pills.locator("[data-choice-id]");
+ await expect(choices).toHaveCount(2);
+ await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
+ // 4. The trail stays closed.
+ await expect(
+ asked.locator('[data-slot="coach-turn-steps-toggle"]'),
+ ).toHaveAttribute("aria-expanded", "false");
+
+ // 2. A tap sends the label as the person's message.
+ const walking = choices.nth(1);
+ const label = (await walking.textContent())?.trim() ?? "";
+ await walking.click();
+ await expect(
+ page
+ .locator('[data-slot="coach-bubble-user"]')
+ .filter({ hasText: label }),
+ ).toBeVisible();
+ await expect(pills).toHaveCount(0);
+ const sent = await posts(page);
+ expect(sent[1]).toMatchObject({
+ conversationId: CONVERSATION_ID,
+ message: label,
+ clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
+ });
+
+ stub.messages.push(
+ { id: "coach-clarify-e2e-u2", role: "user", content: label },
+ {
+ id: "coach-clarify-e2e-a2",
+ role: "assistant",
+ content: ANSWER,
+ metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
+ },
+ );
+ await feed(page, [
+ ...tokens(ANSWER),
+ done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
+ ]);
+ await end(page);
+ await expect(
+ page
+ .locator('[data-slot="coach-bubble-assistant"]')
+ .filter({ hasText: ANSWER }),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-slot="coach-suggested-replies"]'),
+ ).toHaveCount(0);
+ });
+
+ test("a typed answer answers the question too", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "viewport-independent; desktop run suffices",
+ );
+ await page.clock.setFixedTime(FIXED_NOW);
+ await serveAiBlock(page, aiBlockAvailable());
+ const stub: StubConversation = {
+ id: CONVERSATION_ID,
+ title: "Heart rate",
+ messages: [],
+ results: {},
+ trails: {},
+ };
+ await serveConversation(page, stub);
+ await installLiveCoachStream(page);
+ await applyTheme(page, "dark");
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+
+ await ask(page, stub);
+ await expect(
+ page.locator('[data-slot="coach-suggested-replies"]'),
+ ).toBeVisible();
+
+ await page
+ .locator('[data-slot="coach-input-textarea"]')
+ .fill("the walking one");
+ await page.locator('[data-slot="coach-input-send"]').click();
+ await expect(
+ page
+ .locator('[data-slot="coach-bubble-user"]')
+ .filter({ hasText: "the walking one" }),
+ ).toBeVisible();
+ const sent = await posts(page);
+ expect(sent[1]).toMatchObject({
+ message: "the walking one",
+ clarification: { messageId: "coach-clarify-e2e-a1" },
+ });
+ expect(
+ (sent[1].clarification as Record).choiceId,
+ ).toBeUndefined();
+ await end(page);
+ });
+});
diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts
index a5dfc1478..2f7581165 100644
--- a/e2e/coach-dialog.spec.ts
+++ b/e2e/coach-dialog.spec.ts
@@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* puts the table on the clipboard.
* 3. A follow-up chip under the latest reply sends its label with
* `followUp: { messageId, id }`.
- * 4. A reply that ends in a clarifying question shows the choices card
- * and no chips (as the server sends it); a choice sends its label with
- * `clarification: { messageId, choiceId }`, and the card leaves once a
+ * 4. A reply that ends in a clarifying question offers its choices as
+ * reply pills under it and no chips (as the server sends it); there is
+ * no card. A choice sends its label with
+ * `clarification: { messageId, choiceId }`, and the pills leave once a
* plain answer is the latest reply.
- * 5. axe finds nothing on the answer, the chips and the clarification
- * card, in the light and the dark theme.
+ * 5. axe finds nothing on the answer, the chips and the reply pills, in
+ * the light and the dark theme.
*
* The chat POST is a stubbed event stream in the documented frame order,
* and the conversation detail it refetches after `done` returns the same
@@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) {
const result = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
.include('[data-slot="coach-follow-up-chips"]')
- .include('[data-slot="coach-clarification-card"]')
+ .include('[data-slot="coach-suggested-replies"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
@@ -581,26 +582,31 @@ test.describe("Coach dialog", () => {
});
expect(posts[1].clarification).toBeUndefined();
- // 4. The reply asks; the card offers the choices, and the chips of
- // the earlier reply are gone.
- const card = page.locator('[data-slot="coach-clarification-card"]');
- await expect(card).toBeVisible();
- await expect(card).toHaveAttribute(
+ // 4. The reply asks; pills under it offer the choices, and the chips
+ // of the earlier reply are gone. No card.
+ await expect(
+ page.locator('[data-slot="coach-clarification-card"]'),
+ ).toHaveCount(0);
+ const pills = secondBubble.locator(
+ '[data-slot="coach-suggested-replies"]',
+ );
+ await expect(pills).toBeVisible();
+ await expect(pills).toHaveAttribute(
"data-message-id",
TURNS[1].assistantId,
);
- await expect(card.locator("[data-choice-id]")).toHaveCount(2);
+ await expect(pills.locator("[data-choice-id]")).toHaveCount(2);
await expect(chips).toHaveCount(0);
// The earlier result still renders under its own answer.
await expect(figure).toBeVisible();
- // 5. axe over the answers, the result in both views and the card.
+ // 5. axe over the answers, the result in both views and the pills.
await expectNoAxeViolations(page, `${theme} chart view with a question`);
await figure.locator('[data-slot="coach-result-view-table"]').click();
await expect(table).toBeVisible();
await expectNoAxeViolations(page, `${theme} table view with a question`);
- await card.locator('[data-choice-id="c2"]').click();
+ await pills.locator('[data-choice-id="c2"]').click();
const thirdBubble = page
.locator('[data-slot="coach-bubble-assistant"]')
@@ -616,7 +622,7 @@ test.describe("Coach dialog", () => {
expect(posts[2].followUp).toBeUndefined();
// A plain answer closes the question and offers no chips.
- await expect(card).toHaveCount(0);
+ await expect(pills).toHaveCount(0);
await expect(chips).toHaveCount(0);
await expect(
thirdBubble.locator('[data-slot="coach-turn-steps"]'),
diff --git a/e2e/coach-memory.spec.ts b/e2e/coach-memory.spec.ts
new file mode 100644
index 000000000..e9ed1973d
--- /dev/null
+++ b/e2e/coach-memory.spec.ts
@@ -0,0 +1,436 @@
+import type { Page, Route } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+import {
+ FIXED_NOW,
+ done,
+ end,
+ feed,
+ installLiveCoachStream,
+ posts,
+ serveConversation,
+ tokens,
+ applyTheme,
+ type StubConversation,
+} from "./setup/coach-live-stream";
+
+/**
+ * What the Coach keeps, in the same quiet language as everything else in
+ * the thread, and the thinking depth in its quick settings.
+ *
+ * 1. A fact it saved: one meta line under the answer, "Remembered: …",
+ * with Undo, which forgets it.
+ * 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as
+ * reply pills, a tap answers with `memoryDecision`.
+ * 3. A plan it proposes: "Take on this plan" / "Not now", with
+ * `planDecision`. No plan card in the thread.
+ * 4. The memory list in Settings → Coach: an entry is edited in place.
+ * 5. The thinking depth: levels above the operator's cap are disabled and
+ * say so; with reasoning switched off the field is locked with one
+ * sentence.
+ */
+
+const CONVERSATION_ID = "coach-memory-e2e";
+
+function fulfilJson(route: Route, data: unknown, status = 200) {
+ return route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify({ data, error: null }),
+ });
+}
+
+interface Fact {
+ id: string;
+ category: string;
+ text: string;
+ confidence: number;
+ source: string;
+ createdAt: string;
+}
+
+async function serveFacts(page: Page, facts: Fact[]) {
+ const calls: Array<{ method: string; path: string; body: unknown }> = [];
+ await page.route(
+ /\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/,
+ async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ const method = request.method();
+ calls.push({
+ method,
+ path: url.pathname,
+ body: method === "GET" ? null : request.postDataJSON(),
+ });
+ const id = url.pathname.split("/").pop() ?? "";
+ if (method === "DELETE") {
+ const at = facts.findIndex((f) => f.id === id);
+ if (at !== -1) facts.splice(at, 1);
+ return fulfilJson(route, { deleted: true });
+ }
+ if (method === "PATCH") {
+ const fact = facts.find((f) => f.id === id);
+ const next = (request.postDataJSON() as { fact: string }).fact;
+ if (fact) fact.text = next;
+ return fulfilJson(route, { fact });
+ }
+ return fulfilJson(route, { facts });
+ },
+ );
+ return calls;
+}
+
+function stub(): StubConversation {
+ return {
+ id: CONVERSATION_ID,
+ title: "Goals",
+ messages: [],
+ results: {},
+ trails: {},
+ };
+}
+
+async function send(page: Page, text: string) {
+ await page.locator('[data-slot="coach-input-textarea"]').fill(text);
+ await page.locator('[data-slot="coach-input-send"]').click();
+}
+
+async function answer(
+ page: Page,
+ conversation: StubConversation,
+ turn: {
+ userId: string;
+ userText: string;
+ id: string;
+ text: string;
+ metricSource: Record;
+ frames: unknown[];
+ providerType?: string;
+ },
+) {
+ conversation.messages.push(
+ { id: turn.userId, role: "user", content: turn.userText },
+ {
+ id: turn.id,
+ role: "assistant",
+ content: turn.text,
+ metricSource: turn.metricSource,
+ providerType: turn.providerType,
+ },
+ );
+ await feed(page, [
+ ...tokens(turn.text),
+ { type: "provenance", metricSource: turn.metricSource },
+ ...turn.frames,
+ done(CONVERSATION_ID, turn.id),
+ ]);
+ await end(page);
+ await expect(
+ page
+ .locator('[data-slot="coach-bubble-assistant"]')
+ .filter({ hasText: turn.text }),
+ ).toBeVisible();
+}
+
+test.describe("Coach memory and plans", () => {
+ test.use({ storageState: STORAGE_STATE_PATH });
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "viewport-independent; desktop run suffices",
+ );
+ await page.clock.setFixedTime(FIXED_NOW);
+ await applyTheme(page, "light");
+ });
+
+ test("a saved fact is one quiet line, and Undo forgets it", async ({
+ page,
+ }) => {
+ await serveAiBlock(page, aiBlockAvailable());
+ const facts: Fact[] = [
+ {
+ id: "fact-1",
+ category: "goal",
+ text: "You want to reach 75 kg by December",
+ confidence: 1,
+ source: "coach",
+ createdAt: "2026-10-06T09:00:00.000Z",
+ },
+ ];
+ const calls = await serveFacts(page, facts);
+ const conversation = stub();
+ await serveConversation(page, conversation);
+ await installLiveCoachStream(page);
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+
+ await send(page, "I want to reach 75 kg by December.");
+ await answer(page, conversation, {
+ userId: "coach-memory-e2e-u1",
+ userText: "I want to reach 75 kg by December.",
+ id: "coach-memory-e2e-a1",
+ text: "That is about 3 kg from where you are now, a steady pace over ten weeks.",
+ metricSource: {
+ windows: [],
+ metrics: ["weight"],
+ memoryNote: { proposal: false, factId: "fact-1", category: "goal" },
+ },
+ frames: [
+ {
+ type: "memoryNote",
+ note: {
+ proposal: false,
+ factId: "fact-1",
+ category: "goal",
+ fact: "You want to reach 75 kg by December",
+ },
+ },
+ ],
+ });
+
+ const note = page.locator('[data-slot="coach-memory-note"]');
+ await expect(note).toHaveText(
+ /Remembered: You want to reach 75 kg by December\.\s*Undo/,
+ );
+ // No card, no accent: a meta line.
+ await expect(note).toHaveClass(/text-muted-foreground/);
+ await note.locator('[data-slot="coach-memory-note-undo"]').click();
+ await expect(note).toHaveCount(0);
+ expect(
+ calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")),
+ ).toBe(true);
+ });
+
+ test("a proposed fact and a proposed plan are answered with reply pills", async ({
+ page,
+ }) => {
+ await serveAiBlock(page, aiBlockAvailable());
+ await serveFacts(page, []);
+ const conversation = stub();
+ await serveConversation(page, conversation);
+ await installLiveCoachStream(page);
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+
+ // A health fact is proposed, never saved by itself.
+ await send(page, "I started taking ramipril in May.");
+ await answer(page, conversation, {
+ userId: "coach-memory-e2e-u1",
+ userText: "I started taking ramipril in May.",
+ id: "coach-memory-e2e-a1",
+ text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?",
+ metricSource: {
+ windows: [],
+ metrics: ["bp"],
+ memoryNote: {
+ proposal: true,
+ proposalId: "p1",
+ category: "medication",
+ },
+ },
+ frames: [
+ {
+ type: "memoryNote",
+ note: {
+ proposal: true,
+ proposalId: "p1",
+ category: "medication",
+ fact: "Takes ramipril since May",
+ },
+ },
+ ],
+ });
+ await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount(
+ 0,
+ );
+ const pills = page.locator('[data-slot="coach-suggested-replies"]');
+ await expect(pills.locator("[data-reply-id]")).toHaveText([
+ "Yes, remember it",
+ "No",
+ ]);
+ await pills.locator('[data-reply-id="memory-accept"]').click();
+ await expect(
+ page
+ .locator('[data-slot="coach-bubble-user"]')
+ .filter({ hasText: "Yes, remember it" }),
+ ).toBeVisible();
+ expect((await posts(page))[1]).toMatchObject({
+ message: "Yes, remember it",
+ memoryDecision: {
+ messageId: "coach-memory-e2e-a1",
+ proposalId: "p1",
+ accept: true,
+ },
+ });
+ await answer(page, conversation, {
+ userId: "coach-memory-e2e-u2",
+ userText: "Yes, remember it",
+ id: "coach-memory-e2e-a2",
+ text: "Got it, I'll remember.",
+ providerType: "decision",
+ metricSource: { windows: [], metrics: [] },
+ frames: [],
+ });
+ await expect(pills).toHaveCount(0);
+
+ // A plan is one sentence in the answer, then two pills. No card.
+ await send(page, "How do I stop snacking late?");
+ await answer(page, conversation, {
+ userId: "coach-memory-e2e-u3",
+ userText: "How do I stop snacking late?",
+ id: "coach-memory-e2e-a3",
+ text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.",
+ metricSource: {
+ windows: [],
+ metrics: [],
+ planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 },
+ },
+ frames: [
+ {
+ type: "planProposal",
+ proposal: {
+ planId: "plan-1",
+ metric: "WEIGHT",
+ reviewInDays: 14,
+ ifCue: "When you finish dinner",
+ thenAction: "brush your teeth right away",
+ },
+ },
+ ],
+ });
+ await expect(
+ page.locator('[data-slot="coach-plan-proposal-card"]'),
+ ).toHaveCount(0);
+ await expect(pills.locator("[data-reply-id]")).toHaveText([
+ "Take on this plan",
+ "Not now",
+ ]);
+ await pills.locator('[data-reply-id="plan-decline"]').click();
+ expect((await posts(page))[3]).toMatchObject({
+ message: "Not now",
+ planDecision: {
+ messageId: "coach-memory-e2e-a3",
+ planId: "plan-1",
+ accept: false,
+ },
+ });
+ await end(page);
+ });
+
+ test("an entry in the memory list is edited in place", async ({ page }) => {
+ await serveAiBlock(page, aiBlockAvailable());
+ const calls = await serveFacts(page, [
+ {
+ id: "fact-1",
+ category: "goal",
+ text: "Wants to reach 80 kg by autumn",
+ confidence: 1,
+ source: "user",
+ createdAt: "2026-10-01T09:00:00.000Z",
+ },
+ {
+ id: "fact-2",
+ category: "preference",
+ text: "Prefers morning workouts",
+ confidence: 0.8,
+ source: "coach",
+ createdAt: "2026-10-02T09:00:00.000Z",
+ },
+ ]);
+ await page.goto("/settings/coach#coach-memory", {
+ waitUntil: "domcontentloaded",
+ });
+ const card = page.locator("#coach-memory");
+ const rows = card.locator('[data-testid="settings-coach-memory-fact"]');
+ await expect(rows).toHaveCount(2);
+ await expect(rows.nth(0)).toContainText("from you");
+ await expect(rows.nth(1)).toContainText("from the Coach");
+
+ await rows
+ .nth(0)
+ .locator('[data-slot="settings-coach-memory-edit"]')
+ .click();
+ const input = rows
+ .nth(0)
+ .locator('[data-slot="settings-coach-memory-edit-input"]');
+ await input.fill("Wants to reach 78 kg by December");
+ await rows
+ .nth(0)
+ .locator('[data-slot="settings-coach-memory-edit-save"]')
+ .click();
+ await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December");
+ expect(
+ calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1"))
+ ?.body,
+ ).toEqual({ fact: "Wants to reach 78 kg by December" });
+ });
+
+ for (const scenario of [
+ {
+ name: "capped at Medium",
+ block: {
+ level: "medium",
+ preference: "medium",
+ maxLevel: "medium",
+ available: true,
+ offIsReal: true,
+ source: "admin_cap",
+ },
+ },
+ {
+ name: "switched off by the operator",
+ block: {
+ level: "off",
+ preference: "medium",
+ maxLevel: "off",
+ available: false,
+ offIsReal: true,
+ source: "admin_off",
+ },
+ },
+ ]) {
+ test(`thinking depth in the quick settings, ${scenario.name}`, async ({
+ page,
+ }) => {
+ await page.route("**/api/auth/me", async (route) => {
+ const response = await route.fetch();
+ const body = (await response.json()) as {
+ data: Record | null;
+ };
+ if (body.data) {
+ body.data.ai = aiBlockAvailable();
+ body.data.coachReasoning = scenario.block;
+ }
+ await route.fulfill({ response, json: body });
+ });
+ await serveFacts(page, []);
+ await serveConversation(page, stub());
+ await page.goto("/coach", { waitUntil: "domcontentloaded" });
+ await page.locator('[data-slot="coach-settings"]').first().click();
+ const select = page.locator('[data-slot="coach-reasoning-select"]');
+ await expect(select).toBeVisible();
+ await expect(select.locator("option")).toHaveText([
+ "Off",
+ "Low",
+ "Medium",
+ scenario.block.available ? "High (limited by the admin)" : "High",
+ ]);
+ const field = page.locator('[data-slot="coach-reasoning-field"]');
+ if (scenario.block.available) {
+ await expect(select).toBeEnabled();
+ await expect(select.locator('option[value="high"]')).toBeDisabled();
+ await expect(field).toContainText("a little more waiting");
+ } else {
+ await expect(select).toBeDisabled();
+ await expect(field).toContainText(
+ "The admin has turned deeper thinking off.",
+ );
+ }
+ // The memory link names how much the Coach keeps.
+ await expect(
+ page.locator('[data-slot="coach-settings-memory-link"]'),
+ ).toHaveText("Memory (0)");
+ });
+ }
+});
diff --git a/e2e/setup/coach-live-stream.ts b/e2e/setup/coach-live-stream.ts
new file mode 100644
index 000000000..e82e97954
--- /dev/null
+++ b/e2e/setup/coach-live-stream.ts
@@ -0,0 +1,220 @@
+import type { Page, Request, Route } from "@playwright/test";
+
+/**
+ * A Coach chat stream the spec drives frame by frame.
+ *
+ * `route.fulfill` hands the browser the whole event stream at once, so the
+ * live states of a turn (the status line moving from "Thinking…" to
+ * "Fetching…" to "Summarising…", an interim table before the first token)
+ * would flash by unobserved. This replaces `fetch` in the page for the chat
+ * POST only: the response body is a stream the spec feeds with
+ * `feed(page, frames)` and closes with `end(page)`. Every other request goes
+ * through untouched, so `page.route` mocks keep working.
+ *
+ * The POST bodies are recorded in the page (`posts(page)`).
+ */
+export async function installLiveCoachStream(page: Page): Promise {
+ await page.addInitScript(() => {
+ const w = window as unknown as {
+ __coachPosts: unknown[];
+ __coachFeed: (frames: unknown[]) => void;
+ __coachEnd: () => void;
+ };
+ const original = window.fetch.bind(window);
+ let controller: ReadableStreamDefaultController | null = null;
+ const encoder = new TextEncoder();
+ w.__coachPosts = [];
+ w.__coachFeed = (frames) => {
+ for (const frame of frames) {
+ controller?.enqueue(
+ encoder.encode(`data: ${JSON.stringify(frame)}\n\n`),
+ );
+ }
+ };
+ w.__coachEnd = () => {
+ controller?.close();
+ controller = null;
+ };
+ window.fetch = async (input, init) => {
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+ const method = (
+ init?.method ?? (input instanceof Request ? input.method : "GET")
+ ).toUpperCase();
+ if (
+ method === "POST" &&
+ new URL(url, window.location.href).pathname === "/api/insights/chat"
+ ) {
+ w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}")));
+ const body = new ReadableStream({
+ start(c) {
+ controller = c;
+ },
+ });
+ init?.signal?.addEventListener("abort", () => {
+ try {
+ controller?.error(new DOMException("aborted", "AbortError"));
+ } catch {
+ // already closed
+ }
+ });
+ return new Response(body, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ return original(input, init);
+ };
+ });
+}
+
+export async function feed(page: Page, frames: unknown[]): Promise {
+ await page.evaluate((f) => {
+ (window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed(
+ f,
+ );
+ }, frames);
+}
+
+export async function end(page: Page): Promise {
+ await page.evaluate(() => {
+ (window as unknown as { __coachEnd: () => void }).__coachEnd();
+ });
+}
+
+export async function posts(
+ page: Page,
+): Promise>> {
+ return page.evaluate(
+ () =>
+ (window as unknown as { __coachPosts: Array> })
+ .__coachPosts,
+ );
+}
+
+export function tokens(text: string) {
+ return text.split(/(?<= )/).map((token) => ({ type: "token", token }));
+}
+
+function fulfilJson(route: Route, data: unknown) {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data, error: null }),
+ });
+}
+
+export interface StoredMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ metricSource?: Record | null;
+ providerType?: string | null;
+}
+
+/**
+ * The conversation as the stubbed server holds it. The spec pushes the
+ * persisted turn before it sends `done`, so the refetch after `done` finds
+ * the copy that replaces the streamed reply.
+ */
+export interface StubConversation {
+ id: string;
+ title: string;
+ messages: StoredMessage[];
+ /** `…/messages/{id}/results`, by message id. */
+ results: Record;
+ /** `…/messages/{id}/trail`, by message id. */
+ trails: Record;
+}
+
+export async function serveConversation(
+ page: Page,
+ conversation: StubConversation,
+): Promise {
+ const at = (index: number) =>
+ new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString();
+ const summary = () => ({
+ id: conversation.id,
+ title: conversation.title,
+ createdAt: at(0),
+ updatedAt: at(conversation.messages.length),
+ messageCount: conversation.messages.length,
+ fenced: false,
+ attachments: [],
+ documentTitle: null,
+ });
+ await page.route(
+ /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
+ async (route: Route, request: Request) => {
+ const url = new URL(request.url());
+ const base = `/api/insights/chat/${conversation.id}`;
+ const message = url.pathname.match(
+ /\/messages\/([^/]+)\/(results|trail)$/,
+ );
+ if (message) {
+ const [, id, kind] = message;
+ return kind === "results"
+ ? fulfilJson(route, { results: conversation.results[id] ?? [] })
+ : fulfilJson(route, {
+ trail: conversation.trails[id] ?? null,
+ ai: { available: true, reason: null },
+ });
+ }
+ if (url.pathname === base) {
+ return fulfilJson(route, {
+ ...summary(),
+ attachmentCount: 0,
+ summary: null,
+ messages: conversation.messages.map((m, index) => ({
+ id: m.id,
+ role: m.role,
+ content: m.content,
+ createdAt: at(index + 1),
+ metricSource: m.metricSource ?? null,
+ providerType:
+ m.role === "assistant" ? (m.providerType ?? "mock") : null,
+ promptVersion: null,
+ tokensUsed: m.role === "assistant" ? 420 : null,
+ model: m.role === "assistant" ? "mock" : null,
+ })),
+ });
+ }
+ if (url.pathname === "/api/insights/chat" && request.method() === "GET") {
+ return fulfilJson(route, {
+ conversations: conversation.messages.length > 0 ? [summary()] : [],
+ nextCursor: null,
+ });
+ }
+ return route.fallback();
+ },
+ );
+ await page.route("**/api/insights/coach/nudge-status*", (route) =>
+ fulfilJson(route, { nudgedAt: null, unread: false }),
+ );
+ await page.route("**/api/coach/about-me/questions*", (route) =>
+ fulfilJson(route, { questions: [] }),
+ );
+}
+
+export function done(conversationId: string, messageId: string) {
+ return {
+ type: "done",
+ conversationId,
+ messageId,
+ usage: { totalTokens: 420 },
+ };
+}
+
+export async function applyTheme(page: Page, theme: "light" | "dark") {
+ await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
+ await page.addInitScript((value: string) => {
+ window.localStorage.setItem("healthlog-theme", value);
+ }, theme);
+}
+
+/** Midday, so no "today / yesterday" boundary moves a label. */
+export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00");
diff --git a/messages/de.json b/messages/de.json
index cc16bbd08..90baf90f3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Merken",
- "done": "Gemerkt — in deiner Selbstauskunft gespeichert",
- "duplicate": "Schon in deiner Selbstauskunft enthalten",
- "failed": "Merken fehlgeschlagen. Bitte erneut versuchen."
+ "failed": "Merken fehlgeschlagen. Bitte erneut versuchen.",
+ "alreadyKept": "Das weiß der Coach schon."
},
"guided": {
"offerTitle": "Der Coach hat {count} Rückfragen an dich",
@@ -3019,7 +3018,7 @@
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.",
"baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.",
"baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.",
+ "sameHoursWithin": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also im Bereich.",
+ "sameHoursAbove": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darüber.",
+ "sameHoursBelow": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darunter.",
+ "steadyWithin": "Deine Werte liegen meist bei {low} {unit}; heute passt {value} dazu.",
+ "steadyAbove": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darüber.",
+ "steadyBelow": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darunter.",
+ "latestWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darin.",
+ "latestAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.",
+ "latestBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.",
+ "steadyLatestWithin": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} genau dort.",
+ "steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.",
+ "steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.",
"insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "{count} Bereiche angesehen",
"info": "Details zur Antwort",
"infoModel": "Modell: {model}"
- }
+ },
+ "activity": {
+ "thinking": "Denkt nach…",
+ "fetching": "Holt {domain}, {window}…",
+ "digestOne": "Fasst {count} Wert zusammen…",
+ "digestFew": "Fasst {count} Werte zusammen…",
+ "digestOther": "Fasst {count} Werte zusammen…",
+ "digestDoneOne": "{count} Wert aus {areas}",
+ "digestDoneFew": "{count} Werte aus {areas}",
+ "digestDoneOther": "{count} Werte aus {areas}",
+ "areasOne": "{count} Bereich",
+ "areasFew": "{count} Bereichen",
+ "areasOther": "{count} Bereichen",
+ "memoryOne": "Erinnert sich an {count} Eintrag",
+ "memoryFew": "Erinnert sich an {count} Einträge",
+ "memoryOther": "Erinnert sich an {count} Einträge",
+ "remember": "Merkt sich etwas…",
+ "plan": "Schlägt einen Plan vor…",
+ "asking": "Fragt nach…",
+ "answer": "Schreibt die Antwort…",
+ "summaryOne": "Nachgedacht, {lookups} Abfrage, {seconds} s",
+ "summaryFew": "Nachgedacht, {lookups} Abfragen, {seconds} s",
+ "summaryOther": "Nachgedacht, {lookups} Abfragen, {seconds} s",
+ "summaryNoLookups": "Nachgedacht, {seconds} s",
+ "stop": {
+ "budget": "Budget erreicht, antwortet mit dem, was da ist",
+ "time": "Zeit erreicht, antwortet mit dem, was da ist",
+ "cap": "Viele Schritte, fasst jetzt zusammen",
+ "noProgress": "Keine neuen Daten mehr, fasst zusammen"
+ }
+ },
+ "memory": {
+ "saved": "Gemerkt: {fact}",
+ "undo": "Rückgängig",
+ "accept": "Ja, merken",
+ "decline": "Nein",
+ "confirmed": "Gemerkt.",
+ "declined": "Alles klar, dann nicht."
+ },
+ "plan": {
+ "accept": "Plan übernehmen",
+ "decline": "Nicht jetzt",
+ "confirmed": "Ist notiert. Ich schaue in {days} Tagen mit dir drauf.",
+ "declined": "Alles klar, dann nicht jetzt."
+ },
+ "assumption": {
+ "line": "Angenommen: {value}",
+ "change": "Ändern"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Antwortvorschläge"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} Wert",
+ "labelFew": "{title}, {window}, {count} Werte",
+ "labelOther": "{title}, {window}, {count} Werte"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} gegen {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Denktiefe",
+ "hint": "Gründlicher denken heißt genauere Antworten und etwas mehr Wartezeit.",
+ "off": "Aus",
+ "minimal": "Minimal",
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "high": "Hoch",
+ "capped": "(vom Admin begrenzt)",
+ "disabled": "Der Admin hat gründliches Denken abgeschaltet."
+ },
+ "memoryLink": "Gedächtnis ({count})"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Ziele",
"categoryConstraint": "Einschränkungen",
"categoryContext": "Kontext",
- "forget": "Vergessen",
"forgetAria": "Diese Angabe vergessen",
"forgetTitle": "Diese Notiz vergessen?",
"forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.",
@@ -6008,7 +6091,8 @@
"forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
"clearedToast": "{count} gespeicherte Angaben vergessen.",
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen.",
- "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte."
+ "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.",
+ "categoryMedication": "Medikamente"
},
"coachReminders": {
"title": "Deine Erinnerungen",
@@ -6124,7 +6208,8 @@
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch"
- }
+ },
+ "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach ist aktiviert.",
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
+ },
+ "memory": {
+ "edit": "Bearbeiten",
+ "source": {
+ "user": "von dir",
+ "coach": "vom Coach"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Dokumente lesen",
"description": "KI-Lesen gespeicherter Dokumente, Scans von Laborbefunden und das Erkennen von Medikamenten aus Text. Der größte Anteil der Daten, die den Server verlassen."
+ },
+ "reasoning": {
+ "enabled": "Gründliches Denken erlauben",
+ "max": "Höchste Denktiefe",
+ "description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "Siehe auch: {metric}",
"continue": "Weitersuchen",
"settingLabel": "Anschlussfragen vorschlagen",
- "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage."
+ "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage.",
+ "changeAssumption": "Stattdessen: {value}"
},
"clarify": {
"cardLabel": "Der Coach hat eine Rückfrage",
@@ -9946,6 +10044,12 @@
"last90days": "Letzte 90 Tage",
"lastYear": "Letzte 12 Monate",
"allTime": "Gesamter Zeitraum"
+ },
+ "comparison": {
+ "baseline90d": "Mit meinen üblichen 90 Tagen vergleichen"
+ },
+ "anchor": {
+ "illness": "Seit der Erkrankung vom {date}"
}
},
"reuse": {
diff --git a/messages/en.json b/messages/en.json
index 9fd033567..cb3e738ea 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Remember",
- "done": "Saved to your self-context",
- "duplicate": "Already part of your self-context",
- "failed": "Could not save. Please try again."
+ "failed": "Could not save. Please try again.",
+ "alreadyKept": "The Coach already remembers this."
},
"guided": {
"offerTitle": "The Coach has {count} follow-up questions for you",
@@ -3019,7 +3018,7 @@
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.",
"baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.",
"baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.",
+ "sameHoursWithin": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is within that.",
+ "sameHoursAbove": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is above that.",
+ "sameHoursBelow": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is below that.",
+ "steadyWithin": "Your readings usually sit at {low} {unit}; today's {value} matches.",
+ "steadyAbove": "Your readings usually sit at {low} {unit}; today's {value} is above that.",
+ "steadyBelow": "Your readings usually sit at {low} {unit}; today's {value} is below that.",
+ "latestWithin": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, within it.",
+ "latestAbove": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, above it.",
+ "latestBelow": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, below it.",
+ "steadyLatestWithin": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value} and matches.",
+ "steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.",
+ "steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.",
"insufficient": "Still learning your usual range — keep logging for a few more days.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "Looked at {count} areas",
"info": "Answer details",
"infoModel": "Model: {model}"
- }
+ },
+ "activity": {
+ "thinking": "Thinking…",
+ "fetching": "Fetching {domain}, {window}…",
+ "digestOne": "Summarising {count} reading…",
+ "digestFew": "Summarising {count} readings…",
+ "digestOther": "Summarising {count} readings…",
+ "digestDoneOne": "{count} reading from {areas}",
+ "digestDoneFew": "{count} readings from {areas}",
+ "digestDoneOther": "{count} readings from {areas}",
+ "areasOne": "{count} area",
+ "areasFew": "{count} areas",
+ "areasOther": "{count} areas",
+ "memoryOne": "Recalls {count} thing you told it",
+ "memoryFew": "Recalls {count} things you told it",
+ "memoryOther": "Recalls {count} things you told it",
+ "remember": "Remembering something…",
+ "plan": "Drafting a plan…",
+ "asking": "Asking you…",
+ "answer": "Writing the answer…",
+ "summaryOne": "Thought it through, {lookups} lookup, {seconds} s",
+ "summaryFew": "Thought it through, {lookups} lookups, {seconds} s",
+ "summaryOther": "Thought it through, {lookups} lookups, {seconds} s",
+ "summaryNoLookups": "Thought it through, {seconds} s",
+ "stop": {
+ "budget": "Budget reached, answering with what it has",
+ "time": "Time limit reached, answering with what it has",
+ "cap": "Many steps, wrapping up",
+ "noProgress": "No new data, wrapping up"
+ }
+ },
+ "memory": {
+ "saved": "Remembered: {fact}",
+ "undo": "Undo",
+ "accept": "Yes, remember it",
+ "decline": "No",
+ "confirmed": "Got it, I'll remember.",
+ "declined": "Okay, I won't."
+ },
+ "plan": {
+ "accept": "Take on this plan",
+ "decline": "Not now",
+ "confirmed": "Noted. I'll check in with you in {days} days.",
+ "declined": "Okay, not now."
+ },
+ "assumption": {
+ "line": "Assumed: {value}",
+ "change": "Change"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Suggested replies"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} reading",
+ "labelFew": "{title}, {window}, {count} readings",
+ "labelOther": "{title}, {window}, {count} readings"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} vs {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Thinking depth",
+ "hint": "Deeper thinking means more precise answers and a little more waiting.",
+ "off": "Off",
+ "minimal": "Minimal",
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High",
+ "capped": "(limited by the admin)",
+ "disabled": "The admin has turned deeper thinking off."
+ },
+ "memoryLink": "Memory ({count})"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Goals",
"categoryConstraint": "Constraints",
"categoryContext": "Context",
- "forget": "Forget",
"forgetAria": "Forget this fact",
"forgetTitle": "Forget this fact?",
"forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.",
@@ -6008,7 +6091,8 @@
"forgotError": "Could not forget that fact. Please try again.",
"clearedToast": "Forgot {count} stored facts.",
"clearError": "Could not clear the stored facts. Please try again.",
- "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot."
+ "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot.",
+ "categoryMedication": "Medications"
},
"coachReminders": {
"title": "Your reminders",
@@ -6124,7 +6208,8 @@
"low": "Low",
"medium": "Medium",
"high": "High"
- }
+ },
+ "coachNote": "In the Coach, the thinking depth from the Coach settings applies."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach activated.",
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
+ },
+ "memory": {
+ "edit": "Edit",
+ "source": {
+ "user": "from you",
+ "coach": "from the Coach"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Reading documents",
"description": "AI reading of stored documents, lab report scans and medication extraction from text. The largest share of data leaving the server."
+ },
+ "reasoning": {
+ "enabled": "Allow deeper thinking",
+ "max": "Highest thinking depth",
+ "description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "See also: {metric}",
"continue": "Keep looking",
"settingLabel": "Suggest follow-up questions",
- "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer."
+ "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer.",
+ "changeAssumption": "Instead: {value}"
},
"clarify": {
"cardLabel": "The Coach has a question",
@@ -9946,6 +10044,12 @@
"last90days": "Last 90 days",
"lastYear": "Last 12 months",
"allTime": "All time"
+ },
+ "comparison": {
+ "baseline90d": "Compare with my usual 90 days"
+ },
+ "anchor": {
+ "illness": "Since the illness of {date}"
}
},
"reuse": {
diff --git a/messages/es.json b/messages/es.json
index 47605ac34..531bcc740 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Recordar",
- "done": "Guardado en tu información personal",
- "duplicate": "Ya está en tu información personal",
- "failed": "No se pudo guardar. Inténtalo de nuevo."
+ "failed": "No se pudo guardar. Inténtalo de nuevo.",
+ "alreadyKept": "El Coach ya lo recuerda."
},
"guided": {
"offerTitle": "El Coach tiene {count} preguntas para ti",
@@ -3019,7 +3018,7 @@
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.",
"baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.",
"baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.",
+ "sameHoursWithin": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está dentro.",
+ "sameHoursAbove": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por encima.",
+ "sameHoursBelow": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por debajo.",
+ "steadyWithin": "Tus valores suelen estar en {low} {unit}; hoy {value} coincide.",
+ "steadyAbove": "Tus valores suelen estar en {low} {unit}; hoy {value} está por encima.",
+ "steadyBelow": "Tus valores suelen estar en {low} {unit}; hoy {value} está por debajo.",
+ "latestWithin": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, dentro de él.",
+ "latestAbove": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por encima.",
+ "latestBelow": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por debajo.",
+ "steadyLatestWithin": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value} y coincide.",
+ "steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.",
+ "steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.",
"insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "{count} áreas consultadas",
"info": "Detalles de la respuesta",
"infoModel": "Modelo: {model}"
- }
+ },
+ "activity": {
+ "thinking": "Pensando…",
+ "fetching": "Consultando {domain}, {window}…",
+ "digestOne": "Resumiendo {count} valor…",
+ "digestFew": "Resumiendo {count} valores…",
+ "digestOther": "Resumiendo {count} valores…",
+ "digestDoneOne": "{count} valor de {areas}",
+ "digestDoneFew": "{count} valores de {areas}",
+ "digestDoneOther": "{count} valores de {areas}",
+ "areasOne": "{count} área",
+ "areasFew": "{count} áreas",
+ "areasOther": "{count} áreas",
+ "memoryOne": "Recuerda {count} cosa que le contaste",
+ "memoryFew": "Recuerda {count} cosas que le contaste",
+ "memoryOther": "Recuerda {count} cosas que le contaste",
+ "remember": "Recordando algo…",
+ "plan": "Preparando un plan…",
+ "asking": "Te pregunta…",
+ "answer": "Escribiendo la respuesta…",
+ "summaryOne": "Lo ha pensado, {lookups} consulta, {seconds} s",
+ "summaryFew": "Lo ha pensado, {lookups} consultas, {seconds} s",
+ "summaryOther": "Lo ha pensado, {lookups} consultas, {seconds} s",
+ "summaryNoLookups": "Lo ha pensado, {seconds} s",
+ "stop": {
+ "budget": "Presupuesto alcanzado, responde con lo que tiene",
+ "time": "Tiempo agotado, responde con lo que tiene",
+ "cap": "Muchos pasos, ahora lo resume",
+ "noProgress": "No hay datos nuevos, lo resume"
+ }
+ },
+ "memory": {
+ "saved": "Recordado: {fact}",
+ "undo": "Deshacer",
+ "accept": "Sí, recuérdalo",
+ "decline": "No",
+ "confirmed": "Entendido, lo recordaré.",
+ "declined": "De acuerdo, no lo haré."
+ },
+ "plan": {
+ "accept": "Adoptar este plan",
+ "decline": "Ahora no",
+ "confirmed": "Anotado. Lo revisaremos juntos dentro de {days} días.",
+ "declined": "De acuerdo, ahora no."
+ },
+ "assumption": {
+ "line": "Supuesto: {value}",
+ "change": "Cambiar"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Respuestas sugeridas"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} valor",
+ "labelFew": "{title}, {window}, {count} valores",
+ "labelOther": "{title}, {window}, {count} valores"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} frente a {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Profundidad de razonamiento",
+ "hint": "Pensar más a fondo significa respuestas más precisas y un poco más de espera.",
+ "off": "Desactivado",
+ "minimal": "Mínimo",
+ "low": "Bajo",
+ "medium": "Medio",
+ "high": "Alto",
+ "capped": "(limitado por el administrador)",
+ "disabled": "El administrador ha desactivado el razonamiento profundo."
+ },
+ "memoryLink": "Memoria ({count})"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Objetivos",
"categoryConstraint": "Restricciones",
"categoryContext": "Contexto",
- "forget": "Olvidar",
"forgetAria": "Olvidar este dato",
"forgetTitle": "¿Olvidar este dato?",
"forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.",
@@ -6008,7 +6091,8 @@
"forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.",
"clearedToast": "Se olvidaron {count} datos guardados.",
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo.",
- "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide."
+ "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.",
+ "categoryMedication": "Medicamentos"
},
"coachReminders": {
"title": "Tus recordatorios",
@@ -6124,7 +6208,8 @@
"low": "Bajo",
"medium": "Medio",
"high": "Alto"
- }
+ },
+ "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach activado.",
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
+ },
+ "memory": {
+ "edit": "Editar",
+ "source": {
+ "user": "de ti",
+ "coach": "del Coach"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Lectura de documentos",
"description": "Lectura con IA de documentos guardados, escaneo de informes de laboratorio y extracción de medicamentos a partir de texto. La mayor parte de los datos que salen del servidor."
+ },
+ "reasoning": {
+ "enabled": "Permitir razonamiento profundo",
+ "max": "Profundidad máxima de razonamiento",
+ "description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "Ver también: {metric}",
"continue": "Seguir buscando",
"settingLabel": "Sugerir preguntas de seguimiento",
- "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach."
+ "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach.",
+ "changeAssumption": "En su lugar: {value}"
},
"clarify": {
"cardLabel": "El Coach tiene una pregunta",
@@ -9946,6 +10044,12 @@
"last90days": "Últimos 90 días",
"lastYear": "Últimos 12 meses",
"allTime": "Todo el periodo"
+ },
+ "comparison": {
+ "baseline90d": "Comparar con mis 90 días habituales"
+ },
+ "anchor": {
+ "illness": "Desde la enfermedad del {date}"
}
},
"reuse": {
diff --git a/messages/fr.json b/messages/fr.json
index 87a42569d..e13156a46 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Mémoriser",
- "done": "Enregistré dans vos informations personnelles",
- "duplicate": "Déjà présent dans vos informations personnelles",
- "failed": "Échec de l’enregistrement. Veuillez réessayer."
+ "failed": "Échec de l’enregistrement. Veuillez réessayer.",
+ "alreadyKept": "Le Coach s'en souvient déjà."
},
"guided": {
"offerTitle": "Le Coach a {count} questions pour vous",
@@ -3019,7 +3018,7 @@
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.",
"baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.",
"baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.",
+ "sameHoursWithin": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} s'y situe.",
+ "sameHoursAbove": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe au-dessus.",
+ "sameHoursBelow": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe en dessous.",
+ "steadyWithin": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} correspond.",
+ "steadyAbove": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe au-dessus.",
+ "steadyBelow": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe en dessous.",
+ "latestWithin": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, dans cette plage.",
+ "latestAbove": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.",
+ "latestBelow": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.",
+ "steadyLatestWithin": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, comme d'habitude.",
+ "steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.",
+ "steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.",
"insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "{count} domaines consultés",
"info": "Détails de la réponse",
"infoModel": "Modèle : {model}"
- }
+ },
+ "activity": {
+ "thinking": "Réfléchit…",
+ "fetching": "Récupère {domain}, {window}…",
+ "digestOne": "Résume {count} mesure…",
+ "digestFew": "Résume {count} mesures…",
+ "digestOther": "Résume {count} mesures…",
+ "digestDoneOne": "{count} mesure sur {areas}",
+ "digestDoneFew": "{count} mesures sur {areas}",
+ "digestDoneOther": "{count} mesures sur {areas}",
+ "areasOne": "{count} domaine",
+ "areasFew": "{count} domaines",
+ "areasOther": "{count} domaines",
+ "memoryOne": "Se souvient de {count} chose que tu lui as dite",
+ "memoryFew": "Se souvient de {count} choses que tu lui as dites",
+ "memoryOther": "Se souvient de {count} choses que tu lui as dites",
+ "remember": "Retient quelque chose…",
+ "plan": "Propose un plan…",
+ "asking": "Te pose une question…",
+ "answer": "Rédige la réponse…",
+ "summaryOne": "Réflexion faite, {lookups} recherche, {seconds} s",
+ "summaryFew": "Réflexion faite, {lookups} recherches, {seconds} s",
+ "summaryOther": "Réflexion faite, {lookups} recherches, {seconds} s",
+ "summaryNoLookups": "Réflexion faite, {seconds} s",
+ "stop": {
+ "budget": "Budget atteint, répond avec ce qu'il a",
+ "time": "Temps écoulé, répond avec ce qu'il a",
+ "cap": "Beaucoup d'étapes, conclut maintenant",
+ "noProgress": "Plus de nouvelles données, conclut"
+ }
+ },
+ "memory": {
+ "saved": "Retenu : {fact}",
+ "undo": "Annuler",
+ "accept": "Oui, retiens-le",
+ "decline": "Non",
+ "confirmed": "C'est noté, je m'en souviendrai.",
+ "declined": "D'accord, je ne le ferai pas."
+ },
+ "plan": {
+ "accept": "Adopter ce plan",
+ "decline": "Pas maintenant",
+ "confirmed": "C'est noté. On fait le point ensemble dans {days} jours.",
+ "declined": "D'accord, pas maintenant."
+ },
+ "assumption": {
+ "line": "Hypothèse : {value}",
+ "change": "Modifier"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Réponses suggérées"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} mesure",
+ "labelFew": "{title}, {window}, {count} mesures",
+ "labelOther": "{title}, {window}, {count} mesures"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} contre {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Profondeur de réflexion",
+ "hint": "Réfléchir plus en profondeur donne des réponses plus précises, avec un peu plus d'attente.",
+ "off": "Désactivé",
+ "minimal": "Minimale",
+ "low": "Faible",
+ "medium": "Moyenne",
+ "high": "Élevée",
+ "capped": "(limité par l'administrateur)",
+ "disabled": "L'administrateur a désactivé la réflexion approfondie."
+ },
+ "memoryLink": "Mémoire ({count})"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Objectifs",
"categoryConstraint": "Contraintes",
"categoryContext": "Contexte",
- "forget": "Oublier",
"forgetAria": "Oublier cette information",
"forgetTitle": "Oublier ce fait ?",
"forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.",
@@ -6008,7 +6091,8 @@
"forgotError": "Impossible d'oublier cette information. Veuillez réessayer.",
"clearedToast": "{count} informations enregistrées oubliées.",
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer.",
- "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie."
+ "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.",
+ "categoryMedication": "Médicaments"
},
"coachReminders": {
"title": "Tes rappels",
@@ -6124,7 +6208,8 @@
"low": "Faible",
"medium": "Moyen",
"high": "Élevé"
- }
+ },
+ "coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach activé.",
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
+ },
+ "memory": {
+ "edit": "Modifier",
+ "source": {
+ "user": "de toi",
+ "coach": "du Coach"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Lecture de documents",
"description": "Lecture par IA des documents enregistrés, scan des comptes rendus de laboratoire et extraction de médicaments à partir de texte. La plus grande part des données qui quittent le serveur."
+ },
+ "reasoning": {
+ "enabled": "Autoriser la réflexion approfondie",
+ "max": "Profondeur de réflexion maximale",
+ "description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "Voir aussi : {metric}",
"continue": "Continuer à chercher",
"settingLabel": "Suggérer des questions de suivi",
- "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach."
+ "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach.",
+ "changeAssumption": "À la place : {value}"
},
"clarify": {
"cardLabel": "Le Coach a une question",
@@ -9946,6 +10044,12 @@
"last90days": "90 derniers jours",
"lastYear": "12 derniers mois",
"allTime": "Toute la période"
+ },
+ "comparison": {
+ "baseline90d": "Comparer avec mes 90 jours habituels"
+ },
+ "anchor": {
+ "illness": "Depuis la maladie du {date}"
}
},
"reuse": {
diff --git a/messages/it.json b/messages/it.json
index 8a150e8a1..bda0a89a9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Memorizza",
- "done": "Salvato nelle tue informazioni personali",
- "duplicate": "Già presente nelle tue informazioni personali",
- "failed": "Salvataggio non riuscito. Riprova."
+ "failed": "Salvataggio non riuscito. Riprova.",
+ "alreadyKept": "Il Coach lo ricorda già."
},
"guided": {
"offerTitle": "Il Coach ha {count} domande per te",
@@ -3019,7 +3018,7 @@
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.",
"baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.",
"baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.",
+ "sameHoursWithin": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} rientra nell'intervallo.",
+ "sameHoursAbove": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sopra.",
+ "sameHoursBelow": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sotto.",
+ "steadyWithin": "I tuoi valori sono di solito {low} {unit}; oggi {value} corrisponde.",
+ "steadyAbove": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sopra.",
+ "steadyBelow": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sotto.",
+ "latestWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al suo interno.",
+ "latestAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.",
+ "latestBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.",
+ "steadyLatestWithin": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, in linea.",
+ "steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.",
+ "steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.",
"insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "{count} aree consultate",
"info": "Dettagli della risposta",
"infoModel": "Modello: {model}"
- }
+ },
+ "activity": {
+ "thinking": "Sta pensando…",
+ "fetching": "Recupera {domain}, {window}…",
+ "digestOne": "Riassume {count} valore…",
+ "digestFew": "Riassume {count} valori…",
+ "digestOther": "Riassume {count} valori…",
+ "digestDoneOne": "{count} valore da {areas}",
+ "digestDoneFew": "{count} valori da {areas}",
+ "digestDoneOther": "{count} valori da {areas}",
+ "areasOne": "{count} area",
+ "areasFew": "{count} aree",
+ "areasOther": "{count} aree",
+ "memoryOne": "Ricorda {count} cosa che gli hai detto",
+ "memoryFew": "Ricorda {count} cose che gli hai detto",
+ "memoryOther": "Ricorda {count} cose che gli hai detto",
+ "remember": "Memorizza qualcosa…",
+ "plan": "Prepara un piano…",
+ "asking": "Ti fa una domanda…",
+ "answer": "Scrive la risposta…",
+ "summaryOne": "Ci ha pensato, {lookups} ricerca, {seconds} s",
+ "summaryFew": "Ci ha pensato, {lookups} ricerche, {seconds} s",
+ "summaryOther": "Ci ha pensato, {lookups} ricerche, {seconds} s",
+ "summaryNoLookups": "Ci ha pensato, {seconds} s",
+ "stop": {
+ "budget": "Budget raggiunto, risponde con ciò che ha",
+ "time": "Tempo scaduto, risponde con ciò che ha",
+ "cap": "Molti passaggi, ora riassume",
+ "noProgress": "Nessun dato nuovo, riassume"
+ }
+ },
+ "memory": {
+ "saved": "Memorizzato: {fact}",
+ "undo": "Annulla",
+ "accept": "Sì, ricordalo",
+ "decline": "No",
+ "confirmed": "Ricevuto, me lo ricorderò.",
+ "declined": "D'accordo, non lo faccio."
+ },
+ "plan": {
+ "accept": "Adotta questo piano",
+ "decline": "Non ora",
+ "confirmed": "Annotato. Ne riparliamo insieme tra {days} giorni.",
+ "declined": "D'accordo, non ora."
+ },
+ "assumption": {
+ "line": "Ipotesi: {value}",
+ "change": "Cambia"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Risposte suggerite"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} valore",
+ "labelFew": "{title}, {window}, {count} valori",
+ "labelOther": "{title}, {window}, {count} valori"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} rispetto a {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Profondità di ragionamento",
+ "hint": "Pensare più a fondo significa risposte più precise e un po' più di attesa.",
+ "off": "Disattivato",
+ "minimal": "Minima",
+ "low": "Bassa",
+ "medium": "Media",
+ "high": "Alta",
+ "capped": "(limitato dall'amministratore)",
+ "disabled": "L'amministratore ha disattivato il ragionamento approfondito."
+ },
+ "memoryLink": "Memoria ({count})"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Obiettivi",
"categoryConstraint": "Vincoli",
"categoryContext": "Contesto",
- "forget": "Dimentica",
"forgetAria": "Dimentica questa informazione",
"forgetTitle": "Dimenticare questa informazione?",
"forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.",
@@ -6008,7 +6091,8 @@
"forgotError": "Impossibile dimenticare questa informazione. Riprova.",
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova.",
- "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi."
+ "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.",
+ "categoryMedication": "Farmaci"
},
"coachReminders": {
"title": "I tuoi promemoria",
@@ -6124,7 +6208,8 @@
"low": "Basso",
"medium": "Medio",
"high": "Alto"
- }
+ },
+ "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach attivato.",
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
+ },
+ "memory": {
+ "edit": "Modifica",
+ "source": {
+ "user": "da te",
+ "coach": "dal Coach"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Lettura dei documenti",
"description": "Lettura con IA dei documenti salvati, scansione dei referti di laboratorio ed estrazione dei farmaci dal testo. La quota maggiore dei dati che lasciano il server."
+ },
+ "reasoning": {
+ "enabled": "Consenti ragionamento approfondito",
+ "max": "Profondità massima di ragionamento",
+ "description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "Vedi anche: {metric}",
"continue": "Continua a cercare",
"settingLabel": "Suggerisci domande di approfondimento",
- "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach."
+ "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach.",
+ "changeAssumption": "Invece: {value}"
},
"clarify": {
"cardLabel": "Il Coach ha una domanda",
@@ -9946,6 +10044,12 @@
"last90days": "Ultimi 90 giorni",
"lastYear": "Ultimi 12 mesi",
"allTime": "Intero periodo"
+ },
+ "comparison": {
+ "baseline90d": "Confronta con i miei soliti 90 giorni"
+ },
+ "anchor": {
+ "illness": "Dalla malattia del {date}"
}
},
"reuse": {
diff --git a/messages/ko.json b/messages/ko.json
index 7c6d3661f..42368be2b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "기억하기",
- "done": "셀프 컨텍스트에 저장했어요",
- "duplicate": "이미 셀프 컨텍스트에 있어요",
- "failed": "저장하지 못했어요. 다시 시도해 주세요."
+ "failed": "저장하지 못했어요. 다시 시도해 주세요.",
+ "alreadyKept": "코치가 이미 기억하고 있어요."
},
"guided": {
"offerTitle": "코치가 물어볼 후속 질문이 {count}개 있어요",
@@ -3030,6 +3029,18 @@
"baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 안이에요.",
"baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 위예요.",
"baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 아래예요.",
+ "sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 안이에요.",
+ "sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 위예요.",
+ "sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 아래예요.",
+ "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.",
+ "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.",
+ "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.",
+ "latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 안이었어요.",
+ "latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위보다 높았어요.",
+ "latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 아래였어요.",
+ "steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소와 같았어요.",
+ "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 높았어요.",
+ "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 낮았어요.",
"insufficient": "아직 평소 범위를 배우는 중이에요 — 며칠 더 기록해 주세요.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "영역 {count}개 확인",
"info": "답변 정보",
"infoModel": "모델: {model}"
- }
+ },
+ "activity": {
+ "thinking": "생각하는 중…",
+ "fetching": "{domain} 가져오는 중, {window}…",
+ "digestOne": "값 {count}개 요약 중…",
+ "digestFew": "값 {count}개 요약 중…",
+ "digestOther": "값 {count}개 요약 중…",
+ "digestDoneOne": "{areas}에서 값 {count}개",
+ "digestDoneFew": "{areas}에서 값 {count}개",
+ "digestDoneOther": "{areas}에서 값 {count}개",
+ "areasOne": "{count}개 영역",
+ "areasFew": "{count}개 영역",
+ "areasOther": "{count}개 영역",
+ "memoryOne": "알려 준 내용 {count}개를 떠올리는 중",
+ "memoryFew": "알려 준 내용 {count}개를 떠올리는 중",
+ "memoryOther": "알려 준 내용 {count}개를 떠올리는 중",
+ "remember": "기억해 두는 중…",
+ "plan": "계획을 제안하는 중…",
+ "asking": "질문하는 중…",
+ "answer": "답변을 쓰는 중…",
+ "summaryOne": "생각 완료, 조회 {lookups}회, {seconds}초",
+ "summaryFew": "생각 완료, 조회 {lookups}회, {seconds}초",
+ "summaryOther": "생각 완료, 조회 {lookups}회, {seconds}초",
+ "summaryNoLookups": "생각 완료, {seconds}초",
+ "stop": {
+ "budget": "예산 도달, 지금까지의 내용으로 답변",
+ "time": "시간 제한 도달, 지금까지의 내용으로 답변",
+ "cap": "단계가 많아 이제 정리하는 중",
+ "noProgress": "새 데이터가 없어 정리하는 중"
+ }
+ },
+ "memory": {
+ "saved": "기억함: {fact}",
+ "undo": "실행 취소",
+ "accept": "네, 기억해 줘",
+ "decline": "아니요",
+ "confirmed": "알겠어요, 기억해 둘게요.",
+ "declined": "알겠어요, 저장하지 않을게요."
+ },
+ "plan": {
+ "accept": "이 계획 시작하기",
+ "decline": "나중에",
+ "confirmed": "기록했어요. {days}일 뒤에 함께 살펴볼게요.",
+ "declined": "알겠어요, 지금은 하지 않을게요."
+ },
+ "assumption": {
+ "line": "가정: {value}",
+ "change": "변경"
+ },
+ "suggestedReplies": {
+ "groupLabel": "추천 답변"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, 값 {count}개",
+ "labelFew": "{title}, {window}, 값 {count}개",
+ "labelOther": "{title}, {window}, 값 {count}개"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} 대 {b}"
+ }
+ },
+ "reasoning": {
+ "label": "생각 깊이",
+ "hint": "더 깊이 생각할수록 답변이 정확해지고 조금 더 기다리게 돼요.",
+ "off": "끄기",
+ "minimal": "최소",
+ "low": "낮음",
+ "medium": "중간",
+ "high": "높음",
+ "capped": "(관리자가 제한함)",
+ "disabled": "관리자가 깊은 생각을 꺼 두었어요."
+ },
+ "memoryLink": "기억 ({count})"
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
@@ -5992,7 +6076,6 @@
"categoryGoal": "목표",
"categoryConstraint": "제약",
"categoryContext": "맥락",
- "forget": "잊기",
"forgetAria": "이 정보 잊기",
"forgetTitle": "이 정보를 잊을까요?",
"forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.",
@@ -6008,7 +6091,8 @@
"forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.",
"clearedToast": "저장된 정보 {count}개를 잊었어요.",
"clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.",
- "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요."
+ "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.",
+ "categoryMedication": "약"
},
"coachReminders": {
"title": "내 알림",
@@ -6124,7 +6208,8 @@
"low": "낮음",
"medium": "보통",
"high": "높음"
- }
+ },
+ "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "코치를 켰어요.",
"savedOff": "코치를 숨겼어요.",
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
+ },
+ "memory": {
+ "edit": "편집",
+ "source": {
+ "user": "내가 알려 줌",
+ "coach": "코치가 기억함"
+ }
}
},
"integrationRedirectGuide": {
@@ -7137,6 +7229,11 @@
"documentAi": {
"title": "문서 읽기",
"description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다."
+ },
+ "reasoning": {
+ "enabled": "깊은 생각 허용",
+ "max": "최대 생각 깊이",
+ "description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "함께 보기: {metric}",
"continue": "계속 찾아보기",
"settingLabel": "후속 질문 추천",
- "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요."
+ "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.",
+ "changeAssumption": "대신: {value}"
},
"clarify": {
"cardLabel": "코치가 질문이 있어요",
@@ -9946,6 +10044,12 @@
"last90days": "최근 90일",
"lastYear": "최근 12개월",
"allTime": "전체 기간"
+ },
+ "comparison": {
+ "baseline90d": "평소 90일과 비교하기"
+ },
+ "anchor": {
+ "illness": "{date} 질환 이후부터"
}
},
"reuse": {
diff --git a/messages/pl.json b/messages/pl.json
index c96054e1a..c26366b41 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2836,9 +2836,8 @@
},
"rememberMessage": {
"action": "Zapamiętaj",
- "done": "Zapisano w informacjach o sobie",
- "duplicate": "Już znajduje się w informacjach o sobie",
- "failed": "Nie udało się zapisać. Spróbuj ponownie."
+ "failed": "Nie udało się zapisać. Spróbuj ponownie.",
+ "alreadyKept": "Coach już to pamięta."
},
"guided": {
"offerTitle": "Coach ma {count} pytania do Ciebie",
@@ -3019,7 +3018,7 @@
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
- "thinkingElapsed": "{seconds}s",
+ "thinkingElapsed": "{seconds} s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
@@ -3030,6 +3029,18 @@
"baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.",
"baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.",
"baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.",
+ "sameHoursWithin": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli w zakresie.",
+ "sameHoursAbove": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli powyżej.",
+ "sameHoursBelow": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli poniżej.",
+ "steadyWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest taka sama.",
+ "steadyAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest wyższa.",
+ "steadyBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest niższa.",
+ "latestWithin": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i mieścił się w nim.",
+ "latestAbove": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był powyżej niego.",
+ "latestBelow": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był poniżej niego.",
+ "steadyLatestWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był taki sam.",
+ "steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.",
+ "steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.",
"insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.",
"driver": "{note}"
},
@@ -3146,7 +3157,80 @@
"areasDoneOther": "Sprawdzono {count} obszarów",
"info": "Szczegóły odpowiedzi",
"infoModel": "Model: {model}"
- }
+ },
+ "activity": {
+ "thinking": "Myśli…",
+ "fetching": "Pobiera {domain}, {window}…",
+ "digestOne": "Podsumowuje {count} pomiar…",
+ "digestFew": "Podsumowuje {count} pomiary…",
+ "digestOther": "Podsumowuje {count} pomiarów…",
+ "digestDoneOne": "{count} pomiar z: {areas}",
+ "digestDoneFew": "{count} pomiary z: {areas}",
+ "digestDoneOther": "{count} pomiarów z: {areas}",
+ "areasOne": "{count} obszar",
+ "areasFew": "{count} obszary",
+ "areasOther": "{count} obszarów",
+ "memoryOne": "Pamięta {count} rzecz, którą mu powiedziałeś",
+ "memoryFew": "Pamięta {count} rzeczy, które mu powiedziałeś",
+ "memoryOther": "Pamięta {count} rzeczy, które mu powiedziałeś",
+ "remember": "Zapamiętuje coś…",
+ "plan": "Przygotowuje plan…",
+ "asking": "Dopytuje…",
+ "answer": "Pisze odpowiedź…",
+ "summaryOne": "Przemyślane, {lookups} zapytanie, {seconds} s",
+ "summaryFew": "Przemyślane, {lookups} zapytania, {seconds} s",
+ "summaryOther": "Przemyślane, {lookups} zapytań, {seconds} s",
+ "summaryNoLookups": "Przemyślane, {seconds} s",
+ "stop": {
+ "budget": "Budżet wyczerpany, odpowiada na podstawie tego, co ma",
+ "time": "Limit czasu osiągnięty, odpowiada na podstawie tego, co ma",
+ "cap": "Wiele kroków, teraz podsumowuje",
+ "noProgress": "Brak nowych danych, podsumowuje"
+ }
+ },
+ "memory": {
+ "saved": "Zapamiętane: {fact}",
+ "undo": "Cofnij",
+ "accept": "Tak, zapamiętaj",
+ "decline": "Nie",
+ "confirmed": "Jasne, zapamiętam.",
+ "declined": "Dobrze, nie zapiszę tego."
+ },
+ "plan": {
+ "accept": "Przyjmij ten plan",
+ "decline": "Nie teraz",
+ "confirmed": "Zanotowane. Wrócimy do tego razem za {days} dni.",
+ "declined": "Dobrze, nie teraz."
+ },
+ "assumption": {
+ "line": "Założenie: {value}",
+ "change": "Zmień"
+ },
+ "suggestedReplies": {
+ "groupLabel": "Proponowane odpowiedzi"
+ },
+ "interim": {
+ "labelOne": "{title}, {window}, {count} pomiar",
+ "labelFew": "{title}, {window}, {count} pomiary",
+ "labelOther": "{title}, {window}, {count} pomiarów"
+ },
+ "chart": {
+ "compare": {
+ "periods": "{a} wobec {b}"
+ }
+ },
+ "reasoning": {
+ "label": "Głębokość namysłu",
+ "hint": "Głębszy namysł oznacza dokładniejsze odpowiedzi i trochę dłuższe czekanie.",
+ "off": "Wyłączone",
+ "minimal": "Minimalna",
+ "low": "Niska",
+ "medium": "Średnia",
+ "high": "Wysoka",
+ "capped": "(ograniczone przez administratora)",
+ "disabled": "Administrator wyłączył głębszy namysł."
+ },
+ "memoryLink": "Pamięć ({count})"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -5992,7 +6076,6 @@
"categoryGoal": "Cele",
"categoryConstraint": "Ograniczenia",
"categoryContext": "Kontekst",
- "forget": "Zapomnij",
"forgetAria": "Zapomnij tę informację",
"forgetTitle": "Zapomnieć tę informację?",
"forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.",
@@ -6008,7 +6091,8 @@
"forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.",
"clearedToast": "Zapomniano {count} zapisanych informacji.",
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie.",
- "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał."
+ "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.",
+ "categoryMedication": "Leki"
},
"coachReminders": {
"title": "Twoje przypomnienia",
@@ -6124,7 +6208,8 @@
"low": "Niskie",
"medium": "Średnie",
"high": "Wysokie"
- }
+ },
+ "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
}
},
"dashboard": {
@@ -6663,6 +6748,13 @@
"savedOn": "Coach włączony.",
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
+ },
+ "memory": {
+ "edit": "Edytuj",
+ "source": {
+ "user": "od ciebie",
+ "coach": "od Coacha"
+ }
}
},
"integrationRedirectGuide": {
@@ -7158,6 +7250,11 @@
"documentAi": {
"title": "Czytanie dokumentów",
"description": "Czytanie zapisanych dokumentów przez AI, skanowanie wyników badań laboratoryjnych i odczytywanie leków z tekstu. Największa część danych opuszczających serwer."
+ },
+ "reasoning": {
+ "enabled": "Zezwalaj na głębszy namysł",
+ "max": "Największa głębokość namysłu",
+ "description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów."
}
},
"modules": {
@@ -9933,7 +10030,8 @@
"relatedMetric": "Sprawdź też: {metric}",
"continue": "Szukaj dalej",
"settingLabel": "Proponuj kolejne pytania",
- "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha."
+ "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha.",
+ "changeAssumption": "Zamiast tego: {value}"
},
"clarify": {
"cardLabel": "Coach ma pytanie",
@@ -9946,6 +10044,12 @@
"last90days": "Ostatnie 90 dni",
"lastYear": "Ostatnie 12 miesięcy",
"allTime": "Cały okres"
+ },
+ "comparison": {
+ "baseline90d": "Porównaj z moimi zwykłymi 90 dniami"
+ },
+ "anchor": {
+ "illness": "Od choroby z {date}"
}
},
"reuse": {
diff --git a/package.json b/package.json
index 022eeaa01..b43f4d3c9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.40.6",
+ "version": "1.41.0",
"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",
@@ -59,7 +59,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.9.1",
- "@modelcontextprotocol/sdk": "^1.30.0",
+ "@modelcontextprotocol/sdk": "^1.32.1",
"@napi-rs/canvas": "1.0.9",
"@node-rs/argon2": "^2.1.0",
"@parse/node-apn": "^8.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d7aced555..215c1f798 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,6 +18,7 @@ overrides:
qs@<6.16.0: ^6.16.0
fast-uri@<3.1.8: ^3.1.8
flatted@<3.4.2: ^3.4.2
+ proxy-addr@<2.0.8: ^2.0.8
mysql2@<3.22.0: ^3.22.0
minimatch@<3.1.4: ^3.1.4
ip-address@<10.7.1: ^10.7.1
@@ -56,8 +57,8 @@ importers:
specifier: ^5.9.1
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)
'@modelcontextprotocol/sdk':
- specifier: ^1.30.0
- version: 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
+ specifier: ^1.32.1
+ version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
'@napi-rs/canvas':
specifier: 1.0.9
version: 1.0.9
@@ -1039,8 +1040,8 @@ packages:
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
- '@modelcontextprotocol/sdk@1.30.0':
- resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
+ '@modelcontextprotocol/sdk@1.32.1':
+ resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -5870,8 +5871,8 @@ packages:
resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
engines: {node: '>=12.0.0'}
- proxy-addr@2.0.7:
- resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ proxy-addr@2.0.8:
+ resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
engines: {node: '>= 0.10'}
pump@3.0.4:
@@ -7852,7 +7853,7 @@ snapshots:
'@levischuck/tiny-cbor@0.2.11': {}
- '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.7)
ajv: 8.20.0
@@ -7876,7 +7877,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
+ '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.7)
ajv: 8.20.0
@@ -11248,7 +11249,7 @@ snapshots:
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
- proxy-addr: 2.0.7
+ proxy-addr: 2.0.8
qs: 6.16.0
range-parser: 1.3.0
router: 2.2.0(supports-color@7.2.0)
@@ -12744,7 +12745,7 @@ snapshots:
'@types/node': 26.6.2
long: 5.3.2
- proxy-addr@2.0.7:
+ proxy-addr@2.0.8:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
@@ -13198,7 +13199,7 @@ snapshots:
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@dotenvx/dotenvx': 1.75.1
- '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)
+ '@modelcontextprotocol/sdk': 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.7
commander: 14.0.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a1304d0c1..a35c1a52d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -38,6 +38,10 @@ overrides:
# follow-up fixed in 3.1.8, raised the floor from 3.1.6.
"fast-uri@<3.1.8": "^3.1.8"
"flatted@<3.4.2": "^3.4.2"
+ # GHSA-jqcg-44mw-7w3h: IP spoofing through IPv4-mapped addresses. It
+ # arrives under express in the MCP SDK; the app resolves client IPs itself,
+ # but the copy sits on a production path.
+ "proxy-addr@<2.0.8": "^2.0.8"
# GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials.
# Reached only through prisma's MySQL driver, which this app never opens;
# pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the
diff --git a/prisma/migrations/0376_ai_reasoning_controls/migration.sql b/prisma/migrations/0376_ai_reasoning_controls/migration.sql
new file mode 100644
index 000000000..54c6f36cc
--- /dev/null
+++ b/prisma/migrations/0376_ai_reasoning_controls/migration.sql
@@ -0,0 +1,6 @@
+-- The operator's reasoning controls on the instance row. Both columns carry a
+-- default, so the singleton row reads "reasoning allowed, no cap" the moment
+-- the migration lands and an upgrade needs no admin action.
+ALTER TABLE "app_settings"
+ ADD COLUMN "ai_reasoning_enabled" BOOLEAN NOT NULL DEFAULT true,
+ ADD COLUMN "ai_reasoning_max_effort" TEXT NOT NULL DEFAULT 'high';
diff --git a/prisma/migrations/0377_coach_message_trail/migration.sql b/prisma/migrations/0377_coach_message_trail/migration.sql
new file mode 100644
index 000000000..a3fe696d9
--- /dev/null
+++ b/prisma/migrations/0377_coach_message_trail/migration.sql
@@ -0,0 +1,4 @@
+-- The model-written text of a Coach turn's live trail, encrypted like the
+-- message itself. Nullable and without a default: every existing turn has no
+-- trail, and adding a nullable column rewrites no rows.
+ALTER TABLE "coach_messages" ADD COLUMN "trail_encrypted" BYTEA;
diff --git a/prisma/migrations/0378_coach_fact_source/migration.sql b/prisma/migrations/0378_coach_fact_source/migration.sql
new file mode 100644
index 000000000..f79dab9b8
--- /dev/null
+++ b/prisma/migrations/0378_coach_fact_source/migration.sql
@@ -0,0 +1,8 @@
+-- Where a Coach fact came from, when it was last used in a turn, and the
+-- message it came out of. Every row written before this migration came from
+-- the background extraction or the deterministic matcher, which could not be
+-- told apart, so they all read as `extracted`.
+ALTER TABLE "coach_facts"
+ ADD COLUMN "source" TEXT NOT NULL DEFAULT 'extracted',
+ ADD COLUMN "last_used_at" TIMESTAMP(3),
+ ADD COLUMN "source_message_id" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d5b691e19..dcd081188 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -4950,6 +4950,17 @@ model AppSettings {
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
+ // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
+ // provider-side reasoning off for every surface (the Coach and the three
+ // background jobs that use it); `aiReasoningMaxEffort` caps the level a
+ // person may pick (`low` | `medium` | `high`, an app-side closed enum in
+ // `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by
+ // `src/lib/ai/reasoning/resolve.ts` and published per record on
+ // `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no
+ // cap, so an upgrade needs no admin action.
+ aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
+ aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
+
// v1.18.0 — operator-level server-wide module availability. A DISABLED
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
// empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY
@@ -6435,6 +6446,13 @@ model CoachMessage {
// only the tables' metadata. Read through `readMessageResults` by the
// owner-narrowed results endpoint; NULL on every turn without a table.
resultsEncrypted Bytes? @map("results_encrypted")
+ // v1.41 — the model-written text of the turn's live trail (JSON
+ // `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the
+ // facts recalled into the turn, the text of a fact proposal. AES-256-GCM
+ // like `encryptedContent`. The trail's plaintext metadata (phase, status,
+ // round, catalog label, duration, count, stop reason) rides
+ // `metricSourceJson.activity`. Owner-only; NULL on every turn without one.
+ trailEncrypted Bytes? @map("trail_encrypted")
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@ -6477,6 +6495,18 @@ model CoachFact {
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the fact from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")
+ // v1.41 — who put the fact here, an app-side closed enum
+ // (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user`
+ // (the remember button, or a proposal the person confirmed), `coach` (saved
+ // during a turn), `extracted` (the background extraction, and every row
+ // written before the column existed), `pattern` (the deterministic matcher).
+ source String @default("extracted")
+ // v1.41 — when the fact last went into a Coach turn; NULL until it has.
+ lastUsedAt DateTime? @map("last_used_at")
+ // v1.41 — the message the fact came out of, for "undo" and "from this
+ // conversation". A bare id like `sourceConversationId`, not a relation, so
+ // deleting a conversation does not take the fact with it.
+ sourceMessageId String? @map("source_message_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index ffc137f30..6e28c1d91 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -462,8 +462,8 @@ async function main() {
}
// ───── Coach (Bytes columns) ─────
- // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted"
- // "factEncrypted"
+ // "encryptedContent" "resultsEncrypted" "trailEncrypted" "summaryEncrypted"
+ // "titleEncrypted" "factEncrypted"
results.push(
await rotateBytesColumn(
"CoachMessage",
@@ -480,6 +480,15 @@ async function main() {
prisma.coachMessage,
),
);
+ // v1.41 — the model text of the turn's trail. NULL on every turn without
+ // one, which `rotateBytesColumn` skips.
+ results.push(
+ await rotateBytesColumn(
+ "CoachMessage",
+ "trailEncrypted",
+ prisma.coachMessage,
+ ),
+ );
results.push(
await rotateBytesColumn(
"CoachConversation",
diff --git a/src/__tests__/ai-reasoning-admin-guard.test.ts b/src/__tests__/ai-reasoning-admin-guard.test.ts
new file mode 100644
index 000000000..221419db5
--- /dev/null
+++ b/src/__tests__/ai-reasoning-admin-guard.test.ts
@@ -0,0 +1,176 @@
+/**
+ * v1.41 — the operator's reasoning switch and cap cannot be bypassed.
+ *
+ * Two halves.
+ *
+ * Behaviour: `resolveReasoning` is the one decision. With the switch off it
+ * answers `off` for the Coach and every background job, whatever the person
+ * chose, whoever pays and whatever the provider can do; with it on, no answer
+ * is above the cap. Proven exhaustively over the whole input space.
+ *
+ * Structure: a call carries reasoning only through `CompletionParams.reasoning`.
+ * Every production file outside the provider clients that puts that key on a
+ * call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or
+ * `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }`
+ * would skip the switch, and goes red here.
+ *
+ * Its limit: the structural half reads source text for the setter shapes in
+ * use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`,
+ * `reasoning: resolveJobReasoning(`). A value laundered through another name
+ * slips it; the resolver import is still required of the file that sets it.
+ *
+ * Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts`
+ * with `{ effort: "high", summaries: false }` and drop the import: red.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ BACKGROUND_REASONING_JOBS,
+ REASONING_LEVELS,
+ REASONING_MAX_EFFORTS,
+} from "@/lib/ai/reasoning/levels";
+import {
+ completionReasoning,
+ resolveReasoning,
+ type ReasoningSurface,
+} from "@/lib/ai/reasoning/resolve";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
+
+describe("the switch and the cap win, behaviourally", () => {
+ const supports = [
+ null,
+ {
+ effort: true,
+ summaries: true,
+ liveSummaries: true,
+ stateRoundTrip: true,
+ offIsReal: false,
+ },
+ ];
+
+ it("off reaches no surface as anything but off", () => {
+ let cases = 0;
+ for (const surface of SURFACES)
+ for (const userPref of REASONING_LEVELS)
+ for (const costOwner of ["user", "operator"] as const)
+ for (const support of supports) {
+ const r = resolveReasoning({
+ surface,
+ userPref,
+ admin: { enabled: false, maxEffort: "high" },
+ costOwner,
+ support,
+ });
+ expect(r.effort).toBe("off");
+ expect(r.source).toBe("admin_off");
+ const sent = completionReasoning(r, surface);
+ // A job sends nothing; the Coach sends an explicit off.
+ if (surface === "coach") {
+ expect(sent).toEqual({ effort: "off", summaries: false });
+ } else {
+ expect(sent).toBeUndefined();
+ }
+ cases++;
+ }
+ expect(cases).toBeGreaterThanOrEqual(100);
+ });
+
+ it("no surface ever resolves above the cap", () => {
+ for (const surface of SURFACES)
+ for (const userPref of REASONING_LEVELS)
+ for (const maxEffort of REASONING_MAX_EFFORTS) {
+ const r = resolveReasoning({
+ surface,
+ userPref,
+ admin: { enabled: true, maxEffort },
+ costOwner: "user",
+ });
+ expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual(
+ REASONING_LEVELS.indexOf(maxEffort),
+ );
+ }
+ });
+});
+
+/** The provider clients map an already-resolved value onto their wire. */
+const WIRE_LAYER = (rel: string) =>
+ /^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) ||
+ rel === "lib/ai/provider-runner.ts" ||
+ rel.startsWith("lib/ai/reasoning/");
+
+const SETTERS = [
+ /\breasoning\s*:\s*\{\s*effort\b/,
+ /\{\s*reasoning\s*\}/,
+ /\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/,
+];
+
+const RESOLVER_IMPORT =
+ /from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/;
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+}
+
+describe("every call that carries reasoning took it from the resolver", () => {
+ const files = walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ // Client components bind a form field called `reasoning`, not a call.
+ .filter((p) => !p.startsWith("components/"))
+ .filter((p) => !WIRE_LAYER(p));
+ const setters = files.filter((rel) => {
+ const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
+ return SETTERS.some((re) => re.test(code));
+ });
+
+ it("finds the setters (an empty set is a failure, not a pass)", () => {
+ expect(setters.length).toBeGreaterThanOrEqual(2);
+ expect(setters).toContain("lib/insights/status-provider.ts");
+ expect(setters).toContain("lib/insights/briefing-provider.ts");
+ });
+
+ it("each one imports the resolver", () => {
+ expect(
+ setters.filter(
+ (rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")),
+ ),
+ "A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.",
+ ).toEqual([]);
+ });
+});
+
+/**
+ * The Coach turn receives its level on `TurnInput.reasoningLevel` and carries
+ * it through the loop under its own names, which the setter shapes above do
+ * not see. The one place that level is decided is the chat route, so the
+ * route is pinned: it resolves the level, and it does not fall back to a
+ * constant that would skip the operator's switch.
+ */
+describe("the Coach turn's level comes from the resolver", () => {
+ const route = stripComments(
+ readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"),
+ );
+
+ it("resolves the level in the chat route", () => {
+ expect(route).toMatch(
+ /reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/,
+ );
+ expect(route).toMatch(RESOLVER_IMPORT);
+ });
+
+ it("never hands the turn a constant level", () => {
+ expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/);
+ expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/);
+ });
+});
diff --git a/src/__tests__/ai-reasoning-effort-guard.test.ts b/src/__tests__/ai-reasoning-effort-guard.test.ts
index e555ab141..2c4fac64e 100644
--- a/src/__tests__/ai-reasoning-effort-guard.test.ts
+++ b/src/__tests__/ai-reasoning-effort-guard.test.ts
@@ -3,11 +3,15 @@
* the setting the resolver bound to the provider instance.
*
* Default must send nothing, so the key may not appear on any wire except as
- * the bound value, and only the two clients whose endpoint a person chose may
- * carry it. Pinned here:
+ * the bound value or the call's own resolved level, and only the two clients
+ * whose endpoint a person chose may carry it. Pinned here:
*
* 1. the key appears in exactly the Local client and the OpenAI client,
- * once each, and only as `reasoning_effort: this.reasoningEffort`;
+ * twice each: once as `reasoning_effort: this.reasoningEffort`, sent
+ * only when the call carries no level of its own (`!params.reasoning`),
+ * and once as the call's level (v1.41 `CompletionParams.reasoning`,
+ * resolved upstream against the person's choice and the operator's
+ * cap), never as a literal;
* 2. nothing but the binder assigns `.reasoningEffort`;
* 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
*
@@ -20,6 +24,7 @@
* the behaviour on the wire.
*
* Mutation check: hard-code `reasoning_effort: "none"` into either client,
+ * drop the `!params.reasoning` condition in front of the bound value,
* set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
* from `resolveProviderForTest`: each one goes red here.
*/
@@ -60,13 +65,17 @@ describe("reasoning_effort on the wire", () => {
});
it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
- "%s sends it once, as the bound value",
+ "%s sends the bound value only without a call level, and never a literal",
(rel) => {
const source = code(rel);
- expect(source.match(new RegExp(KEY))?.length).toBe(1);
+ expect(source.match(new RegExp(KEY))?.length).toBe(2);
expect(source).toMatch(
/\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
);
+ expect(source).toMatch(
+ /!\s*params\s*\.\s*reasoning\s*&&\s*(?:this\s*\.\s*isGateway\s*&&\s*)?this\s*\.\s*reasoningEffort\b/,
+ );
+ expect(source).not.toMatch(/\breasoning_effort["']?\s*:\s*["'`]/);
},
);
});
diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts
index 9ee7b02ec..5f3238eb7 100644
--- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts
+++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts
@@ -118,11 +118,17 @@ const BACKFILL_STAMPS: Record = {
/**
* `coachConversation` writes outside backfills that stamp `updatedAt`
- * because the person did something in the conversation, keyed by file.
+ * because the person did something in the conversation, keyed by file, with
+ * how many such writes the file holds. The count matters: keyed by file
+ * alone, a new background write added beside the listed ones would pass
+ * unseen.
*/
-const COACH_ACTIVITY: Record = {
- "src/lib/ai/coach/persistence.ts":
- "a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread",
+const COACH_ACTIVITY: Record = {
+ "src/lib/ai/coach/persistence.ts": {
+ writes: 2,
+ reason:
+ "a rename and a document attach are the person acting on the thread (a turn names updatedAt explicitly)",
+ },
};
describe("backfills keep updatedAt", () => {
@@ -180,13 +186,21 @@ describe("coach conversations move only on activity", () => {
});
it("names updatedAt in every write that is not the person's own action", () => {
- const offenders = writes
- .filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY))
- .map((w) => w.file);
+ const stamping = new Map();
+ for (const w of writes.filter((w) => !w.namesUpdatedAt)) {
+ stamping.set(w.file, (stamping.get(w.file) ?? 0) + 1);
+ }
+ const offenders = [...stamping]
+ .filter(([file, n]) => n !== COACH_ACTIVITY[file]?.writes)
+ .map(([file, n]) => `${file}: ${n} stamping write(s)`);
expect(
- [...new Set(offenders)],
- "a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)",
+ offenders,
+ "a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL); a new activity write is listed with its count",
).toEqual([]);
+ // Every listed file is still there with its writes.
+ expect(Object.keys(COACH_ACTIVITY).filter((f) => !stamping.has(f))).toEqual(
+ [],
+ );
});
});
diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts
new file mode 100644
index 000000000..cafb8bf61
--- /dev/null
+++ b/src/__tests__/background-reasoning-inventory.test.ts
@@ -0,0 +1,128 @@
+/**
+ * v1.41 — which background calls may reason, frozen.
+ *
+ * Reasoning in the background is paid for every night, for every account, by
+ * nobody watching. So it is admitted where it changes the result a person
+ * reads (the daily briefing and the period narratives) and nowhere else: not
+ * on status cards, batch notes, derived scores, workout and reaction lines,
+ * nudges, document reads or OCR, which are routine, latency-bound and
+ * seed-reproducible.
+ *
+ * The only way a background call asks is the `reasoningJob` argument of the
+ * two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test
+ * finds every place that sets it and holds the set, file by file and job by
+ * job, against the inventory below. A status card that started passing a job
+ * goes red here; so does a new caller nobody admitted.
+ *
+ * Its limit: it reads source text for a `reasoningJob:` property and the job
+ * literals beside it. A call that built the argument object elsewhere and
+ * spread it in would slip the matcher; the chokepoint signature is the only
+ * documented way in, and the wire test (`background-reasoning-wire.test.ts`)
+ * proves a call without the argument sends nothing.
+ *
+ * Mutation check: add `reasoningJob: "daily_briefing"` to
+ * `src/lib/insights/status-batch.ts`, or delete it from the narrative
+ * generator: either goes red.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/** File → the jobs it may name, each with the reason it may. */
+const ADMITTED: Record = {
+ "lib/insights/comprehensive-generate.ts": {
+ jobs: ["daily_briefing"],
+ why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.",
+ },
+ "app/api/insights/generate/route.ts": {
+ jobs: ["daily_briefing"],
+ why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.",
+ },
+ "lib/insights/narrative/period-narrative-generate.ts": {
+ jobs: ["period_narrative_month", "period_narrative_week"],
+ why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.",
+ },
+};
+
+/** The routine generators that must never name a job. */
+const NEVER = [
+ "lib/insights/status-batch.ts",
+ "lib/insights/status-card-generation.ts",
+ "lib/insights/metric-status.ts",
+ "lib/insights/biomarker-status.ts",
+ "lib/insights/derived/derived-assessment-ai.ts",
+ "lib/jobs/workout-insight-generate.ts",
+];
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+}
+
+/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */
+const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g;
+
+function scan(): Map }> {
+ const found = new Map }>();
+ const files = walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
+ for (const rel of files) {
+ const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
+ const setters = code.match(SETTER)?.length ?? 0;
+ if (setters === 0) continue;
+ const jobs = new Set();
+ for (const m of code.matchAll(SETTER)) {
+ const window = code.slice(m.index, m.index + 160);
+ for (const job of BACKGROUND_REASONING_JOBS) {
+ if (window.includes(`"${job}"`)) jobs.add(job);
+ }
+ }
+ found.set(rel, { setters, jobs });
+ }
+ return found;
+}
+
+describe("background reasoning inventory", () => {
+ const found = scan();
+
+ it("finds the admitted callers (an empty set is a failure, not a pass)", () => {
+ expect(found.size).toBeGreaterThanOrEqual(2);
+ });
+
+ it("only the admitted files name a job", () => {
+ expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort());
+ });
+
+ it.each(Object.entries(ADMITTED))(
+ "%s names exactly its admitted jobs",
+ (rel, { jobs }) => {
+ expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual(
+ [...jobs].sort(),
+ );
+ },
+ );
+
+ it.each(NEVER)("%s never reasons", (rel) => {
+ const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
+ expect(code).not.toMatch(/\breasoningJob\b/);
+ expect(code).not.toMatch(/\breasoning\s*:/);
+ });
+
+ it("every admitted job is a known background job", () => {
+ for (const { jobs } of Object.values(ADMITTED)) {
+ for (const job of jobs) {
+ expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job);
+ }
+ }
+ });
+});
diff --git a/src/__tests__/coach-focus-ring-guard.test.ts b/src/__tests__/coach-focus-ring-guard.test.ts
new file mode 100644
index 000000000..e51241121
--- /dev/null
+++ b/src/__tests__/coach-focus-ring-guard.test.ts
@@ -0,0 +1,56 @@
+/**
+ * Structural guard on keyboard focus in the Coach panel.
+ *
+ * The purple `--ring` is gone from every Coach control: a tap draws nothing,
+ * keyboard focus draws a solid ring in the neutral text-field focus tone
+ * (`--input-focus`). Two checks:
+ *
+ * 1. No class string under `coach-panel/` names the purple ring, border or
+ * outline on focus. The sweep must find a floor of neutral focus rings,
+ * so a matcher that stops matching fails instead of passing on nothing.
+ * 2. The controls built on the `Button` primitive inside an answer (the
+ * reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the
+ * primitive's purple ring when the classes merge.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const DIR = join(__dirname, "..", "components", "insights", "coach-panel");
+
+const SOURCES = readdirSync(DIR)
+ .filter((name) => /\.(tsx|ts)$/.test(name))
+ .map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") }));
+
+const PURPLE_FOCUS =
+ /(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g;
+
+describe("Coach focus ring", () => {
+ it("never names the purple ring on focus", () => {
+ const offenders = SOURCES.flatMap(({ name, text }) =>
+ [...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`),
+ );
+ expect(offenders).toEqual([]);
+ });
+
+ it("finds the neutral ring where it expects it", () => {
+ const neutral = SOURCES.reduce(
+ (n, { text }) =>
+ n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0),
+ 0,
+ );
+ expect(neutral).toBeGreaterThanOrEqual(15);
+ });
+
+ it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])(
+ "%s merges the neutral ring over the Button primitive",
+ (file) => {
+ const text = SOURCES.find((s) => s.name === file)?.text ?? "";
+ expect(text).toMatch(
+ /import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/,
+ );
+ expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/);
+ },
+ );
+});
diff --git a/src/__tests__/coach-stream-ios-compat.test.ts b/src/__tests__/coach-stream-ios-compat.test.ts
index 8235fb064..2276ef55b 100644
--- a/src/__tests__/coach-stream-ios-compat.test.ts
+++ b/src/__tests__/coach-stream-ios-compat.test.ts
@@ -10,7 +10,8 @@
* shape would make the whole frame fail to decode, and a new frame that
* reused one with the same shape would leak a value into whatever the client
* does with that key. So a new frame names its payload with a key of its own
- * (`step`, `result`, `followUps`, `clarification`).
+ * (`step`, `result`, `followUps`, `clarification`, and since v1.41
+ * `activity`, `memoryNote` with `note`, `planProposal` with `proposal`).
*
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
* type test holds equal to `CoachStreamEvent`, so a frame added to the
@@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () =
"result",
"followUps",
"clarification",
+ "activity",
+ "memoryNote",
+ "planProposal",
]),
);
expect(
diff --git a/src/__tests__/coach-voice-guard.test.ts b/src/__tests__/coach-voice-guard.test.ts
new file mode 100644
index 000000000..2e2475681
--- /dev/null
+++ b/src/__tests__/coach-voice-guard.test.ts
@@ -0,0 +1,149 @@
+/**
+ * v1.41 — the Coach's voice, held in the prompt.
+ *
+ * The voice section (`buildCoachVoiceBlock`) is the one statement of how a
+ * Coach answer is shaped: the answer first, one carrying number, the
+ * assumption in one line, at most one next step, 40-120 words, and a closed
+ * list of filler it never writes. A prompt rewrite that drops a rule, or an
+ * example that models the opposite (a closing question, two next steps, a
+ * filler), would quietly teach the model the old habits back. This guard
+ * pins both bodies to the rules, and every few-shot example to the grader
+ * that scores live answers.
+ *
+ * Each check is a function run once on the real prompt and once on a prompt
+ * broken on purpose, so the guard is shown to fail.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ COACH_ANSWER_WORDS,
+ COACH_FILLER_PHRASES,
+ buildCoachVoiceBlock,
+ getCoachSystemPrompt,
+} from "@/lib/ai/coach/system-prompt";
+import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice";
+import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
+import type { Locale } from "@/lib/i18n/config";
+
+const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
+
+/** The rules a voice section must state; `[]` when it states them all. */
+function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
+ const missing: string[] = [];
+ const band = COACH_ANSWER_WORDS.standard;
+ const header =
+ lang === "de" ? "TON UND ANTWORTAUFBAU" : "VOICE AND ANSWER SHAPE";
+ if (!prompt.includes(header)) missing.push("header");
+ if (!prompt.includes(`${band.min}-${band.max}`)) missing.push("length band");
+ const rules =
+ lang === "de"
+ ? [
+ /erste Satz beantwortet die Frage/,
+ /Eine tragende Zahl/,
+ /Angenommen: letzte/,
+ /höchstens EINEM konkreten nächsten Schritt/,
+ /Nie eine Frage am Ende/,
+ /keine Emojis/,
+ ]
+ : [
+ /first sentence answers the question/,
+ /One carrying number/,
+ /Assumed: last 30 days/,
+ /at most ONE concrete next step/,
+ /Never a question at the end/,
+ /no emojis/,
+ ];
+ for (const rule of rules) if (!rule.test(prompt)) missing.push(rule.source);
+ for (const phrase of COACH_FILLER_PHRASES[lang]) {
+ if (!prompt.includes(`"${phrase}"`)) missing.push(`filler ${phrase}`);
+ }
+ return missing;
+}
+
+/** The COACH lines of the few-shot examples. */
+function exampleAnswers(prompt: string): string[] {
+ const answers: string[] = [];
+ for (const match of prompt.matchAll(/([\s\S]*?)<\/example>/g)) {
+ const coach = match[1].split(/^COACH:\s*/m)[1];
+ if (coach) answers.push(coach.trim());
+ }
+ return answers;
+}
+
+/** Examples that break the voice; `[]` when every one keeps it. */
+function examplesOffVoice(prompt: string): string[] {
+ return exampleAnswers(prompt).filter((answer) => !gradeVoice(answer).passed);
+}
+
+describe("coach voice guard", () => {
+ it.each(["en", "de"] as const)(
+ "the %s body states every voice rule and filler",
+ (lang) => {
+ const prompt = getCoachSystemPrompt(lang);
+ expect(missingVoiceRules(prompt, lang)).toEqual([]);
+ // Broken on purpose: one filler dropped from the list.
+ const broken = prompt.replaceAll(
+ `"${COACH_FILLER_PHRASES[lang][0]}"`,
+ "",
+ );
+ expect(missingVoiceRules(broken, lang)).not.toEqual([]);
+ },
+ );
+
+ it.each(LOCALES)("the %s prompt carries the voice section", (locale) => {
+ expect(getCoachSystemPrompt(locale)).toContain(
+ buildCoachVoiceBlock(locale),
+ );
+ });
+
+ it.each(["en", "de"] as const)(
+ "every %s example answer keeps the voice",
+ (lang) => {
+ const prompt = getCoachSystemPrompt(lang);
+ expect(exampleAnswers(prompt).length).toBeGreaterThanOrEqual(5);
+ expect(examplesOffVoice(prompt)).toEqual([]);
+ // Broken on purpose: an example that ends on a habitual question.
+ const broken = prompt.replace(
+ /([\s\S]*?COACH:[\s\S]*?)(\n(?:---KEYVALUES---|<\/example>))/,
+ "$1 How does that sound to you?$2",
+ );
+ expect(examplesOffVoice(broken)).not.toEqual([]);
+ },
+ );
+
+ it("keeps no length rule from before the voice section", () => {
+ for (const lang of ["en", "de"] as const) {
+ const prompt = getCoachSystemPrompt(lang, {
+ ...DEFAULT_COACH_PREFS,
+ verbosity: "detailed",
+ });
+ expect(prompt).not.toMatch(/60-180/);
+ expect(prompt).not.toMatch(/180-250/);
+ expect(prompt).not.toMatch(/~90/);
+ }
+ });
+
+ it("states the brief and detailed lengths from the same numbers", () => {
+ const brief = getCoachSystemPrompt("en", {
+ ...DEFAULT_COACH_PREFS,
+ verbosity: "brief",
+ });
+ expect(brief).toContain(
+ `${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words`,
+ );
+ const detailed = getCoachSystemPrompt("de", {
+ ...DEFAULT_COACH_PREFS,
+ verbosity: "detailed",
+ });
+ expect(detailed).toContain(`bis ${COACH_ANSWER_WORDS.detailed.max} Wörter`);
+ });
+
+ it("asks a confidence question only on an explicit plan request", () => {
+ expect(getCoachSystemPrompt("en")).toMatch(
+ /Confidence ruler, only on an explicit plan request/,
+ );
+ expect(getCoachSystemPrompt("de")).toMatch(
+ /Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte/,
+ );
+ });
+});
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 34f750ae7..a429ecb1a 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [
},
{
file: "components/settings/coach-memory-section.tsx",
- destroys: "one remembered Coach fact, or every one of them",
+ destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
- confirm: ["ConfirmButton", "AlertDialog"],
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/insights/coach-panel/use-coach-facts.ts",
+ destroys: "one remembered Coach fact",
+ triggers: [
+ "components/settings/coach-memory-section.tsx",
+ "components/insights/coach-panel/memory-note.tsx",
+ ],
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton"],
+ unconfirmed: [
+ {
+ control: "Undo on the memory note under an answer — memory-note.tsx",
+ reason:
+ "Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.",
+ },
+ ],
},
{
file: "hooks/use-coach-reminders.ts",
@@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [
{
file: "hooks/use-coach-plans.ts",
destroys: "a Coach plan and the record of how it went",
- triggers: [
- "app/coach/plans/page.tsx",
- "components/insights/coach-panel/plan-proposal-card.tsx",
- ],
+ triggers: ["app/coach/plans/page.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
- control:
- "decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
+ control: "decline a PROPOSED plan — app/coach/plans/page.tsx",
reason:
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
},
diff --git a/src/__tests__/memory-block-egress-guard.test.ts b/src/__tests__/memory-block-egress-guard.test.ts
new file mode 100644
index 000000000..a874be864
--- /dev/null
+++ b/src/__tests__/memory-block-egress-guard.test.ts
@@ -0,0 +1,158 @@
+/**
+ * v1.41 — what the person told the Coach leaves the machine only when the
+ * turn may send to its providers.
+ *
+ * The memory block (`WHAT YOU KNOW ABOUT THIS PERSON`) carries facts, plans
+ * and reminders the person wrote; the briefing's plan lines carry plan
+ * targets. Both are built, not built-then-dropped: the egress or capability
+ * check runs first and nothing is read on a refusal. Behaviour is pinned in
+ * `memory/__tests__/context-block.test.ts` and `plan-progress.test.ts`; this
+ * guard pins the structure those tests rest on:
+ *
+ * 1. Inside each builder, the check comes before the first database read.
+ * 2. The builders are called only from the files that run after the turn's
+ * own egress check (the Coach turn) or behind the briefing capability (the
+ * briefing generation), and a Coach-turn caller hands over the chain so
+ * the wire check runs for exactly those providers.
+ * 3. No `annotate()` in the memory modules names a text-bearing value.
+ *
+ * Every check is also run against a source broken on purpose.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+const MEMORY = "lib/ai/coach/memory";
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
+}
+
+/** The body of `export async function (`, up to the next top-level `}`. */
+function functionBody(source: string, name: string): string {
+ const start = source.search(
+ new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
+ );
+ if (start === -1) return "";
+ const end = source.indexOf("\n}\n", start);
+ return source.slice(start, end === -1 ? undefined : end);
+}
+
+/** Whether `check` appears before the first database read in `body`. */
+function checksBeforeRead(body: string, check: RegExp): boolean {
+ const checkAt = body.search(check);
+ const readAt = body.search(
+ /\bprisma\.|\bload(?:Facts|Plans|Reminders)\(|takePendingProposal\(/,
+ );
+ return checkAt !== -1 && readAt !== -1 && checkAt < readAt;
+}
+
+describe("memory block egress guard", () => {
+ const block = functionBody(
+ read(`${MEMORY}/context-block.ts`),
+ "buildMemoryContextBlock",
+ );
+ const lines = functionBody(
+ read(`${MEMORY}/plan-progress.ts`),
+ "buildPlanProgressLines",
+ );
+
+ it("the memory block checks egress before it reads anything", () => {
+ expect(block.length).toBeGreaterThan(200);
+ expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true);
+ const helper = read(`${MEMORY}/context-block.ts`);
+ expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/);
+ expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/);
+ // Broken on purpose: the check moved below the reads.
+ const broken = block.replace(/if \(!\(await egressAllowed\(args\)\)\)/, "");
+ expect(checksBeforeRead(broken, /egressAllowed\(/)).toBe(false);
+ });
+
+ it("the briefing's plan lines check the briefing capability before any read", () => {
+ expect(lines.length).toBeGreaterThan(200);
+ expect(
+ checksBeforeRead(lines, /aiCapabilityForRecord\(userId, "briefing"\)/),
+ ).toBe(true);
+ const broken = lines.replace(
+ /aiCapabilityForRecord\(userId, "briefing"\)/,
+ "",
+ );
+ expect(
+ checksBeforeRead(broken, /aiCapabilityForRecord\(userId, "briefing"\)/),
+ ).toBe(false);
+ });
+
+ /**
+ * Files that may build the memory block: the Coach turn, after its own
+ * capability and consent checks (`turn/pipeline.ts` rechecks before it
+ * runs the model). The contract re-exports it; nothing else calls it.
+ */
+ const BLOCK_CALLERS = new Set([
+ "lib/ai/coach/turn/model.ts",
+ "lib/ai/coach/turn/context.ts",
+ ]);
+ /** Files that may build the briefing's plan lines. */
+ const LINE_CALLERS = new Set([
+ "lib/insights/comprehensive-generate.ts",
+ "lib/insights/briefing-provider.ts",
+ ]);
+
+ function callers(name: string): string[] {
+ const call = new RegExp(`\\b${name}\\(`);
+ return sourceFiles()
+ .filter((rel) => !rel.startsWith(`${MEMORY}/`))
+ .filter((rel) => call.test(read(rel)));
+ }
+
+ it("only the Coach turn builds the memory block, and it names its providers", () => {
+ const found = callers("buildMemoryContextBlock");
+ for (const rel of found) {
+ expect(BLOCK_CALLERS.has(rel), rel).toBe(true);
+ expect(read(rel), `${rel} hands over the chain`).toMatch(
+ /buildMemoryContextBlock\(\{[\s\S]{0,400}providerTypes/,
+ );
+ }
+ // The matcher is live: it finds the definition it guards.
+ const definitions = sourceFiles().filter((rel) =>
+ /export async function buildMemoryContextBlock\(/.test(read(rel)),
+ );
+ expect(definitions).toEqual([`${MEMORY}/context-block.ts`]);
+ });
+
+ it("only the briefing builds the plan lines", () => {
+ for (const rel of callers("buildPlanProgressLines")) {
+ expect(LINE_CALLERS.has(rel), rel).toBe(true);
+ }
+ });
+
+ it("no memory annotation names a text-bearing value", () => {
+ const TEXT =
+ /\b(?:fact|text|ifCue|thenAction|target|recalled|note\.fact|userMessage|content)\s*[,:}]/;
+ const offending: string[] = [];
+ const files = walkSourceFiles(join(SRC, MEMORY), { floor: 5 }).filter(
+ (p) => !p.includes("__tests__"),
+ );
+ for (const rel of files) {
+ const source = read(`${MEMORY}/${rel}`);
+ for (const match of source.matchAll(/annotate\(\{[\s\S]*?\}\);/g)) {
+ const meta = match[0].split("meta:")[1] ?? "";
+ if (TEXT.test(meta)) offending.push(`${rel}: ${match[0]}`);
+ }
+ }
+ expect(offending).toEqual([]);
+ // Broken on purpose: a meta that carries the fact.
+ const planted = `annotate({ action: { name: "x" }, meta: { fact: call.fact } });`;
+ expect(TEXT.test(planted.split("meta:")[1])).toBe(true);
+ });
+});
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index bd9e68c89..64fb05022 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
- "src/components/insights/coach-panel/plan-proposal-card.tsx": {
- "text-success": 1,
- },
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
"text-success": 1,
},
diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts
index 296434789..4f6362757 100644
--- a/src/__tests__/unit-preference-display-guard.test.ts
+++ b/src/__tests__/unit-preference-display-guard.test.ts
@@ -390,7 +390,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
"the reply checker's list of unit tokens it recognises in model prose",
},
"lib/ai/coach/eval/golden-cases.ts": {
- lines: 3,
+ lines: 5,
reason: "evaluation fixtures: scripted replies and ideal answers",
},
"lib/ai/coach/eval/red-team.ts": {
diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts
index 1160e4ee8..3e314e53d 100644
--- a/src/app/api/__tests__/ai-route-inventory.ts
+++ b/src/app/api/__tests__/ai-route-inventory.ts
@@ -102,6 +102,11 @@ export const AI_ROUTES: Readonly> = {
capabilities: ["coach"],
why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.",
},
+ "src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts": {
+ kind: "mixed",
+ capabilities: ["coach"],
+ why: "The trail's reasoning text is model-written and served only while the Coach's text may be shown; otherwise 200 with trail null and the ai state.",
+ },
"src/app/api/insights/generate/route.ts": {
kind: "action",
capabilities: ["briefing"],
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 349245bea..20d98d73a 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -332,7 +332,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [
// being available (the GDPR reason), and a timestamp on one's own row is
// data too.
"src/app/api/insights/coach/seen/route.ts",
- "src/app/api/insights/coach/facts/route.ts",
+ // `insights/coach/facts/route.ts` is not listed, by the rule below: it
+ // lists and erases without the Coach gate but keeps it on POST (the
+ // remember button saves to the Coach's memory, which is Coach use).
"src/app/api/insights/coach/facts/[id]/route.ts",
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts
index 41ad2bf8c..89f60dd8b 100644
--- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts
+++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts
@@ -46,6 +46,8 @@ const ALL_ON = {
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
+ aiReasoningEnabled: true,
+ aiReasoningMaxEffort: "high",
};
beforeEach(() => {
@@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => {
expect(body.data.raw.assistantEnabled).toBe(true);
expect(body.data.resolved.coach).toBe(true);
});
+
+ it("publishes the reasoning controls, defaulted on and uncapped", async () => {
+ vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
+ const res = await (GET as unknown as (r: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
+ );
+ const body = (await res.json()) as {
+ data: { reasoning: { enabled: boolean; maxEffort: string } };
+ };
+ expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" });
+ });
+
+ it("reads a stored cap and an off switch back", async () => {
+ vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ ...ALL_ON,
+ aiReasoningEnabled: false,
+ aiReasoningMaxEffort: "low",
+ } as never);
+ const res = await (GET as unknown as (r: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/admin/settings/assistant-flags"),
+ );
+ const body = (await res.json()) as {
+ data: { reasoning: { enabled: boolean; maxEffort: string } };
+ };
+ expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" });
+ });
});
describe("PUT /api/admin/settings/assistant-flags", () => {
@@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
expect(body.data.resolved.documentAi).toBe(false);
});
+ it("switches reasoning off and caps it, with an audit row", async () => {
+ vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
+ vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
+ ...ALL_ON,
+ aiReasoningEnabled: false,
+ aiReasoningMaxEffort: "medium",
+ } as never);
+
+ const res = await PUT(
+ putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }),
+ );
+ expect(res.status).toBe(200);
+ const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
+ expect(upsert.update).toEqual({
+ aiReasoningEnabled: false,
+ aiReasoningMaxEffort: "medium",
+ });
+ const { auditLog } = await import("@/lib/auth/audit");
+ expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({
+ details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" },
+ });
+ const body = (await res.json()) as {
+ data: { reasoning: { enabled: boolean; maxEffort: string } };
+ };
+ expect(body.data.reasoning).toEqual({
+ enabled: false,
+ maxEffort: "medium",
+ });
+ });
+
+ it.each(["off", "xhigh", "", 3])(
+ "refuses %s as the highest level",
+ async (value) => {
+ vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
+ const res = await PUT(putReq({ aiReasoningMaxEffort: value }));
+ expect(res.status).toBe(422);
+ expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
+ },
+ );
+
it("rejects an empty body", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(putReq({}));
diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts
index af5c45ec5..8190a70ba 100644
--- a/src/app/api/admin/settings/assistant-flags/route.ts
+++ b/src/app/api/admin/settings/assistant-flags/route.ts
@@ -28,6 +28,14 @@
* so the admin UI can render the master vs sub-flag distinction
* visually.
*
+ * v1.41 — the same route carries the operator's two reasoning controls:
+ * `aiReasoningEnabled` (off wins over every person's Coach setting and every
+ * background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the
+ * highest level any call may take). Echoed as `reasoning: { enabled,
+ * maxEffort }`. They sit beside the assistant switches rather than in them:
+ * switching reasoning off stops no AI capability, it only stops the model
+ * thinking longer before it answers.
+ *
* `requireAdmin()` gates the route — non-admins get 403.
*/
import type { NextRequest } from "next/server";
@@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { resolveAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
+import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
+import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve";
const assistantFlagsSchema = z
.object({
@@ -53,6 +63,8 @@ const assistantFlagsSchema = z
assistantBriefingEnabled: z.boolean().optional(),
assistantInsightStatusEnabled: z.boolean().optional(),
assistantDocumentAiEnabled: z.boolean().optional(),
+ aiReasoningEnabled: z.boolean().optional(),
+ aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
})
.strict();
@@ -62,8 +74,20 @@ type AssistantFlagsRow = {
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantDocumentAiEnabled: boolean;
+ aiReasoningEnabled: boolean;
+ aiReasoningMaxEffort: string;
};
+const ROW_SELECT = {
+ assistantEnabled: true,
+ assistantCoachEnabled: true,
+ assistantBriefingEnabled: true,
+ assistantInsightStatusEnabled: true,
+ assistantDocumentAiEnabled: true,
+ aiReasoningEnabled: true,
+ aiReasoningMaxEffort: true,
+} as const;
+
function buildResponseShape(row: AssistantFlagsRow) {
const resolved = resolveAssistantFlags({
enabled: row.assistantEnabled,
@@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) {
assistantDocumentAiEnabled: row.assistantDocumentAiEnabled,
},
resolved,
+ reasoning: {
+ enabled: row.aiReasoningEnabled,
+ maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
+ },
};
}
@@ -92,13 +120,7 @@ export const GET = apiHandler(async () => {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
- select: {
- assistantEnabled: true,
- assistantCoachEnabled: true,
- assistantBriefingEnabled: true,
- assistantInsightStatusEnabled: true,
- assistantDocumentAiEnabled: true,
- },
+ select: ROW_SELECT,
});
const row: AssistantFlagsRow = {
@@ -108,6 +130,8 @@ export const GET = apiHandler(async () => {
assistantInsightStatusEnabled:
settings?.assistantInsightStatusEnabled ?? true,
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
+ aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
+ aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
};
return apiSuccess(buildResponseShape(row));
@@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
+ // Field by field from the parsed body; the schema is strict, so every key
+ // here is one the route owns.
const updates: Partial = {};
- const auditDetails: Record = {};
for (const [key, value] of Object.entries(parsed.data)) {
- if (typeof value === "boolean") {
- (updates as Record)[key] = value;
- auditDetails[key] = value;
+ if (value !== undefined) {
+ (updates as Record)[key] = value;
}
}
+ const auditDetails: Record = { ...updates };
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
@@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
+ select: ROW_SELECT,
});
await auditLog("admin.settings.assistant-flags.update", {
@@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
+ aiReasoningEnabled: settings.aiReasoningEnabled,
+ aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
};
return apiSuccess(buildResponseShape(row));
diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts
index 0a5592c0f..b87a157ee 100644
--- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts
+++ b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts
@@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+
+describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => {
+ it("stores the chosen level and names it on the wide event", async () => {
+ vi.mocked(prisma.user.update).mockResolvedValue({
+ updatedAt: new Date("2026-10-06T11:00:00.000Z"),
+ } as never);
+ const { annotate } = await import("@/lib/logging/context");
+
+ const res = await (PUT as (r: Request) => Promise)(
+ mkPut({ reasoning: "high" }),
+ );
+ expect(res.status).toBe(200);
+ const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
+ data: { coachPrefsJson: { reasoning?: string } };
+ };
+ expect(call.data.coachPrefsJson.reasoning).toBe("high");
+ const env = (await res.json()) as { data: { reasoning?: string } };
+ expect(env.data.reasoning).toBe("high");
+ const meta = vi
+ .mocked(annotate)
+ .mock.calls.map((c) => c[0])
+ .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
+ expect(meta).toMatchObject({ reasoning: "high" });
+ });
+
+ it("reads an absent level as the default on the wide event", async () => {
+ vi.mocked(prisma.user.update).mockResolvedValue({
+ updatedAt: new Date("2026-10-06T11:00:00.000Z"),
+ } as never);
+ const { annotate } = await import("@/lib/logging/context");
+
+ await (PUT as (r: Request) => Promise)(mkPut({ tone: "warm" }));
+ const meta = vi
+ .mocked(annotate)
+ .mock.calls.map((c) => c[0])
+ .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta;
+ expect(meta).toMatchObject({ reasoning: "medium" });
+ });
+
+ it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => {
+ const res = await (PUT as (r: Request) => Promise)(
+ mkPut({ reasoning: value }),
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts
index 4e1bf8593..7624b4d5e 100644
--- a/src/app/api/auth/me/coach-prefs/route.ts
+++ b/src/app/api/auth/me/coach-prefs/route.ts
@@ -31,6 +31,7 @@ import {
} from "@/lib/optimistic-lock";
import {
coachPrefsSchema,
+ coachReasoningLevel,
parseCoachPrefs,
} from "@/lib/validations/coach-prefs";
@@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => {
// v1.7.0 — `null` when the caller left `dataClusters` absent
// (legacy defaults stay in force); otherwise the selected count.
clusterCount: parsed.data.dataClusters?.length ?? null,
+ // v1.41 — the stored thinking depth, absent read as the default. The
+ // operator's switch and cap apply on top of it at turn time; the
+ // resolved value is `coachReasoning` on GET /api/auth/me.
+ reasoning: coachReasoningLevel(parsed.data),
},
});
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index e150408eb..4967136e2 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -72,6 +72,8 @@ import {
} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
+import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
+import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
export const dynamic = "force-dynamic";
@@ -143,6 +145,7 @@ export const GET = apiHandler(async () => {
moduleAvailability,
onboardingRow,
ai,
+ coachReasoning,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -206,6 +209,16 @@ export const GET = apiHandler(async () => {
sections,
recordKind,
}),
+ // v1.41 — the Coach's thinking depth, resolved: the caller's own
+ // preference (Coach preferences belong to the person, like every other
+ // display preference here) against the operator's switch and cap and the
+ // record's provider. The provider read is the memoised presence probe the
+ // `ai` block above already ran.
+ loadCoachReasoningState({
+ prefs: parseCoachPrefs(user.coachPrefsJson),
+ recordId,
+ authority: aiAuthority,
+ }),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -397,6 +410,14 @@ export const GET = apiHandler(async () => {
// the RECORD and masked to the grant, like `modules`. Clients render from
// it and never recompute it; data never depends on it.
ai,
+ // v1.41 — the Coach's thinking depth, resolved on the server so the web
+ // settings and the native client show the same answer: `level` is what a
+ // turn runs at now, `preference` what the person picked, `maxLevel` the
+ // highest option the settings may offer (`off` when the operator switched
+ // reasoning off), `available` whether reasoning can run at all,
+ // `offIsReal` whether the lowest option is a real off or "Minimal", and
+ // `source` which layer decided. Clients never recompute it.
+ coachReasoning,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts
index fc368aa71..faf7f89cb 100644
--- a/src/app/api/coach/plans/[id]/route.ts
+++ b/src/app/api/coach/plans/[id]/route.ts
@@ -36,6 +36,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
+import { clampReviewDays } from "@/lib/ai/coach/memory/propose-plan";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
@@ -102,15 +103,25 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
// the current row first so an explicitly-pinned review date is never
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
+ // v1.41 — a plan the Coach proposed in a turn carries its review window as
+ // the distance from the proposal to its `reviewDate`; taking it on here
+ // counts that window from now, exactly like the tap under the answer
+ // (`decidePlanProposal`).
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
const current = await prisma.coachPlan.findFirst({
where: { id, userId: user.id, deletedAt: null },
- select: { reviewDate: true },
+ select: { reviewDate: true, status: true, createdAt: true },
});
if (current && current.reviewDate === null) {
data.reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
);
+ } else if (current?.reviewDate && current.status === "proposed") {
+ const days = clampReviewDays(
+ (current.reviewDate.getTime() - current.createdAt.getTime()) /
+ 86_400_000,
+ );
+ data.reviewDate = new Date(Date.now() + days * 86_400_000);
}
}
diff --git a/src/app/api/coach/plans/route.ts b/src/app/api/coach/plans/route.ts
index 7c656ed40..c379d3b37 100644
--- a/src/app/api/coach/plans/route.ts
+++ b/src/app/api/coach/plans/route.ts
@@ -5,7 +5,10 @@
* Coach proposes. A plan is an "if-then" implementation intention tied to one
* metric, with an optional target. The Coach extractor writes a plan as
* `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`)
- * activates it. There is no silent self-edit of the user's plan set.
+ * activates it. There is no silent self-edit of the user's plan set. A plan
+ * the Coach proposes in a turn (`propose_plan`, v1.41) exists only once the
+ * answer carrying it is stored (`memory/turn-writes.ts`), so a turn that was
+ * blocked, failed or abandoned has nothing here to list.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see their own plans. The free-text fields (if-cue, then-action,
diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts
new file mode 100644
index 000000000..e7eb13654
--- /dev/null
+++ b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts
@@ -0,0 +1,175 @@
+/**
+ * v1.41 — the trail route: owner-narrowed, 404 for anything else, the
+ * model-written text only while the Coach's text may be shown, and the
+ * person's own facts (recalled, proposed) always, minus medication facts
+ * while the medications module is off.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+import type * as ApiHandlerModule from "@/lib/api-handler";
+
+const {
+ requireAuth,
+ readMessageTrail,
+ aiCapabilityToServe,
+ isModuleEnabled,
+ findMany,
+} = vi.hoisted(() => ({
+ requireAuth: vi.fn(),
+ readMessageTrail: vi.fn(),
+ aiCapabilityToServe: vi.fn(),
+ isModuleEnabled: vi.fn(),
+ findMany: vi.fn(),
+}));
+
+vi.mock("@/lib/api-handler", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ apiHandler: unknown>(handler: T) =>
+ handler,
+ requireAuth,
+ };
+});
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
+vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
+vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled }));
+vi.mock("@/lib/db", () => ({ prisma: { coachFact: { findMany } } }));
+vi.mock("@/lib/ai/coach/bytes-codec", () => ({
+ decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
+}));
+
+import { GET } from "../route";
+
+const USER_ID = "user-1";
+const TRAIL = {
+ entries: [
+ {
+ id: "a1",
+ title: "Weighing the two weeks",
+ text: "Looked at sleep first.",
+ },
+ ],
+};
+
+function call(id = "c1", messageId = "m1") {
+ return GET(
+ new NextRequest(
+ `http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`,
+ ),
+ { params: Promise.resolve({ id, messageId }) },
+ ) as Promise;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ requireAuth.mockResolvedValue({ user: { id: USER_ID } });
+ aiCapabilityToServe.mockResolvedValue({
+ available: true,
+ reason: null,
+ onDeviceAllowed: true,
+ });
+ isModuleEnabled.mockResolvedValue(true);
+ findMany.mockResolvedValue([]);
+});
+
+const UNAVAILABLE = {
+ available: false,
+ reason: "disabled_by_user",
+ onDeviceAllowed: false,
+};
+
+const WITH_FACTS = {
+ ...TRAIL,
+ recalled: ["Prefers walks after dinner", "Takes Mounjaro since May"],
+ proposal: {
+ proposalId: "mp_1",
+ category: "condition",
+ fact: "Has asthma",
+ },
+};
+
+describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
+ it("reads under the caller's own id and the path ids only", async () => {
+ readMessageTrail.mockResolvedValue({ trail: TRAIL });
+ const response = await call("c1", "m1");
+ expect(response.status).toBe(200);
+ expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1");
+ // Requires a session or a full-access token, like every Coach read.
+ expect(requireAuth).toHaveBeenCalledWith();
+ const body = await response.json();
+ expect(body.data.trail).toEqual(TRAIL);
+ expect(body.data.ai.available).toBe(true);
+ });
+
+ it("answers 404 for a message the caller does not own", async () => {
+ readMessageTrail.mockResolvedValue(null);
+ const response = await call();
+ expect(response.status).toBe(404);
+ expect(aiCapabilityToServe).not.toHaveBeenCalled();
+ });
+
+ it("serves no model text while the Coach's text may not be shown", async () => {
+ readMessageTrail.mockResolvedValue({ trail: TRAIL });
+ aiCapabilityToServe.mockResolvedValue({
+ available: false,
+ reason: "disabled_by_user",
+ onDeviceAllowed: false,
+ });
+ const response = await call();
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.data.trail).toBeNull();
+ expect(body.data.ai.available).toBe(false);
+ expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
+ });
+
+ it("serves the person's own facts while the model text is withheld", async () => {
+ readMessageTrail.mockResolvedValue({ trail: WITH_FACTS });
+ aiCapabilityToServe.mockResolvedValue(UNAVAILABLE);
+ const body = await (await call()).json();
+ expect(body.data.trail).toEqual({
+ entries: [],
+ recalled: WITH_FACTS.recalled,
+ proposal: WITH_FACTS.proposal,
+ });
+ expect(body.data.ai.available).toBe(false);
+ });
+
+ it("withholds medication facts while the medications module is off", async () => {
+ readMessageTrail.mockResolvedValue({
+ trail: {
+ ...WITH_FACTS,
+ recalled: [...WITH_FACTS.recalled, "Morning pill with coffee"],
+ proposal: {
+ proposalId: "mp_1",
+ category: "medication",
+ fact: "Takes ramipril",
+ },
+ },
+ });
+ isModuleEnabled.mockResolvedValue(false);
+ // A stored fact filed under medication whose words the lexicon misses.
+ findMany.mockResolvedValue([
+ { factEncrypted: new TextEncoder().encode("Morning pill with coffee") },
+ ]);
+ const body = await (await call()).json();
+ expect(isModuleEnabled).toHaveBeenCalledWith(USER_ID, "medications");
+ expect(findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { userId: USER_ID, category: "medication" },
+ }),
+ );
+ expect(body.data.trail).toEqual({
+ entries: TRAIL.entries,
+ recalled: ["Prefers walks after dinner"],
+ });
+ });
+
+ it("answers a message without a trail with null", async () => {
+ readMessageTrail.mockResolvedValue({ trail: null });
+ const body = await (await call()).json();
+ expect(body.data.trail).toBeNull();
+ });
+});
diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts
new file mode 100644
index 000000000..8d3ccb6bc
--- /dev/null
+++ b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts
@@ -0,0 +1,85 @@
+/**
+ * GET /api/insights/chat/[id]/messages/[messageId]/trail — the text of one
+ * assistant message's trail (v1.41), decrypted for its owner: the screened
+ * reasoning titles and summaries and checkpoint sentences by trail entry,
+ * the facts the turn recalled, and a fact proposal it offered. The structure
+ * of the trail (phases, statuses, counts) is plaintext on the message's
+ * `metricSource.activity`; only the text is here, read lazily so a long
+ * thread does not carry every trail.
+ *
+ * Narrowed exactly like `…/results`: the message must sit in a conversation
+ * the caller owns, and a foreign or unknown id is 404, never 403. A session
+ * or a full-access token reads it; a narrow token is refused by
+ * `requireAuth()` like on every Coach read.
+ *
+ * A mixed read, split by who wrote what. The entries' text is model-written,
+ * so it is served only while the Coach's text may be shown for the record
+ * (`aiCapabilityToServe`); when it may not, `entries` is empty, the `ai`
+ * state rides beside it, and the client keeps the trail's catalog labels.
+ * The recalled facts and the proposal's fact are the person's own data and
+ * are served to the owner whatever the capability, like the memory list:
+ * only the medications module gates them, at read time, the way `…/results`
+ * withholds a table whose module is off. `trail` is null when nothing is
+ * left to serve.
+ */
+import type { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
+import { readMessageTrail } from "@/lib/ai/coach/persistence";
+import { withholdMedicationFacts } from "@/lib/ai/coach/memory/trail-read";
+import { isModuleEnabled } from "@/lib/modules/gate";
+import type { CoachTrail } from "@/lib/ai/coach/types";
+
+interface RouteCtx {
+ params: Promise<{ id: string; messageId: string }>;
+}
+
+export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
+ const auth = await requireAuth();
+ const userId = auth.user.id;
+ const { id, messageId } = await ctx.params;
+ if (!id || !messageId) return apiError("coach.message.notFound", 404);
+
+ const read = await readMessageTrail(userId, id, messageId);
+ if (!read) return apiError("coach.message.notFound", 404);
+
+ const ai = await aiCapabilityToServe(userId, "coach");
+ const stored = read.trail;
+ let trail: CoachTrail | null = null;
+ let withheld = 0;
+ if (stored) {
+ const medicationsOn = await isModuleEnabled(userId, "medications");
+ const facts = medicationsOn
+ ? { recalled: stored.recalled, proposal: stored.proposal, withheld: 0 }
+ : await withholdMedicationFacts(userId, stored);
+ withheld = facts.withheld;
+ const entries = ai.available ? stored.entries : [];
+ const recalled =
+ facts.recalled && facts.recalled.length > 0 ? facts.recalled : undefined;
+ if (entries.length > 0 || recalled || facts.proposal) {
+ trail = {
+ entries,
+ ...(recalled ? { recalled } : {}),
+ ...(facts.proposal ? { proposal: facts.proposal } : {}),
+ };
+ }
+ }
+
+ annotate({
+ action: { name: "insights.coach.trail.read" },
+ meta: {
+ conversationId: id,
+ entries: trail?.entries.length ?? 0,
+ recalled: trail?.recalled?.length ?? 0,
+ withheld,
+ served: ai.available,
+ },
+ });
+
+ return apiSuccess({ trail, ai });
+});
+
+export const dynamic = "force-dynamic";
diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
index 66e4f6e98..b9570c8ea 100644
--- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
+++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
@@ -19,6 +19,9 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -26,7 +29,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -50,10 +53,10 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -64,12 +67,33 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg and diastolic 82 mmHg this month.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -100,6 +124,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -107,15 +132,28 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -162,6 +200,16 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -220,6 +268,9 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -227,7 +278,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -251,10 +302,10 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -265,7 +316,18 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -281,6 +343,16 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg, up from […] mmHg last month.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -312,6 +384,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -319,15 +392,28 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -374,6 +460,16 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -433,6 +529,9 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -440,7 +539,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
],
},
{
- "amount": 1800,
+ "amount": 12889,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -464,10 +563,10 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -478,7 +577,18 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11053,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -508,6 +618,16 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
"call": "appendMessage",
"content": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
42,
],
@@ -536,6 +656,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -543,15 +664,28 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "I ",
"type": "token",
@@ -698,6 +832,16 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
42,
],
@@ -754,6 +898,9 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -805,6 +952,36 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg over the window.",
"metricSource": {
+ "activity": [
+ {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -839,6 +1016,7 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -846,9 +1024,11 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
@@ -867,6 +1047,67 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
},
"type": "step",
},
+ {
+ "activity": {
+ "count": 1,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "running",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -901,6 +1142,36 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot
},
{
"metricSource": {
+ "activity": [
+ {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -1080,6 +1351,9 @@ exports[`coach chat golden transcripts > 06 budget exceeded 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1131,6 +1405,9 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1138,7 +1415,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1162,10 +1439,10 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 0,
@@ -1176,7 +1453,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] =
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 12891,
},
{
"call": "annotate",
@@ -1220,6 +1497,9 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1227,7 +1507,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1251,10 +1531,10 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 0,
@@ -1265,7 +1545,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 12891,
},
{
"call": "annotate",
@@ -1309,6 +1589,9 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1316,7 +1599,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1340,10 +1623,10 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 0,
@@ -1354,7 +1637,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 12891,
},
{
"call": "annotate",
@@ -1398,6 +1681,9 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1405,7 +1691,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1429,10 +1715,10 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 0,
@@ -1443,7 +1729,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 12891,
},
{
"call": "annotate",
@@ -1488,6 +1774,9 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1495,7 +1784,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1519,10 +1808,10 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 0,
@@ -1533,7 +1822,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 12891,
},
{
"call": "appendMessage",
@@ -1584,6 +1873,9 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1591,7 +1883,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
],
},
{
- "amount": 1800,
+ "amount": 12892,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1615,10 +1907,10 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -1629,7 +1921,18 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11056,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -1651,6 +1954,16 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
"call": "appendMessage",
"content": "I can't suggest a specific medication dose or a change to one — dose decisions belong with your prescribing clinician. I can help you read what your own data shows around your medication; ask me about that and I'll stay grounded in your numbers.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"metrics": [
"bp",
],
@@ -1672,6 +1985,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -1679,15 +1993,28 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "I ",
"type": "token",
@@ -1866,6 +2193,16 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"metrics": [
"bp",
],
@@ -1915,6 +2252,9 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -1922,7 +2262,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -1946,10 +2286,10 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -1960,12 +2300,33 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2010,6 +2371,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -2017,15 +2379,28 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -2048,6 +2423,16 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2120,6 +2505,9 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2127,7 +2515,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2151,10 +2539,10 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2165,7 +2553,18 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -2181,6 +2580,16 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2219,6 +2628,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -2226,15 +2636,28 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -2257,6 +2680,16 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2323,6 +2756,9 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2330,7 +2766,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2354,10 +2790,10 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2368,7 +2804,18 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -2384,6 +2831,16 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2414,6 +2871,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -2421,15 +2879,28 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -2452,6 +2923,16 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2510,6 +2991,9 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2517,7 +3001,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2541,10 +3025,10 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2555,7 +3039,18 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -2599,6 +3094,9 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2606,7 +3104,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2630,10 +3128,10 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2644,7 +3142,18 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
],
"contentType": "text/event-stream; charset=utf-8",
@@ -2679,6 +3188,9 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2686,7 +3198,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12896,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2710,10 +3222,10 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2724,7 +3236,18 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11059,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1837,
},
{
"call": "prisma.user.update",
@@ -2756,6 +3279,16 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
"call": "appendMessage",
"content": "A short home-measurement week would help.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2791,6 +3324,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -2798,15 +3332,28 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "A ",
"type": "token",
@@ -2833,6 +3380,16 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2904,6 +3461,9 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -2911,7 +3471,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12896,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -2935,10 +3495,10 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -2949,7 +3509,18 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11059,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1837,
},
{
"call": "annotate",
@@ -2963,6 +3534,16 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
"call": "appendMessage",
"content": "A short home-measurement week would help.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -2993,6 +3574,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -3000,15 +3582,28 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "A ",
"type": "token",
@@ -3035,6 +3630,16 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3093,6 +3698,9 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -3100,7 +3708,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12892,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -3124,10 +3732,10 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -3138,7 +3746,18 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11056,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "annotate",
@@ -3151,6 +3770,16 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
"call": "appendMessage",
"content": "An annual blood panel keeps this on track.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3191,6 +3820,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -3198,15 +3828,28 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "An ",
"type": "token",
@@ -3241,6 +3884,16 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3322,6 +3975,9 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -3329,7 +3985,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12896,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -3353,10 +4009,10 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -3367,7 +4023,18 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11059,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1837,
},
{
"call": "captureReminderFromSentinel",
@@ -3385,6 +4052,16 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
"call": "appendMessage",
"content": "Noted, confirm it in settings and I'll bring it back.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3415,6 +4092,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -3422,15 +4100,28 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Noted, ",
"type": "token",
@@ -3473,6 +4164,16 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3531,6 +4232,9 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -3538,7 +4242,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12896,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -3562,10 +4266,10 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -3576,7 +4280,18 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11059,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1837,
},
{
"call": "annotate",
@@ -3589,6 +4304,16 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
"call": "appendMessage",
"content": "Noted, confirm it in settings and I'll bring it back.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3619,6 +4344,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -3626,15 +4352,28 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Noted, ",
"type": "token",
@@ -3677,6 +4416,16 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3735,6 +4484,9 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -3742,7 +4494,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
],
},
{
- "amount": 1800,
+ "amount": 12895,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -3766,10 +4518,10 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -3780,7 +4532,18 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11058,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1837,
},
{
"call": "annotate",
@@ -3795,6 +4558,16 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
"call": "appendMessage",
"content": "Resting heart rate is a good trend signal, /learn/resting-heart-rate.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3825,6 +4598,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -3832,15 +4606,28 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Resting ",
"type": "token",
@@ -3879,6 +4666,16 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -3937,6 +4734,9 @@ exports[`coach chat golden transcripts > 16a coach.provider.none via an empty ch
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "annotate",
"metaKeys": [],
@@ -3976,6 +4776,9 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -4027,6 +4830,36 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg over the window.",
"metricSource": {
+ "activity": [
+ {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4061,6 +4894,7 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -4068,9 +4902,11 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
@@ -4089,6 +4925,67 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
},
"type": "step",
},
+ {
+ "activity": {
+ "count": 1,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "running",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -4123,6 +5020,36 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy
},
{
"metricSource": {
+ "activity": [
+ {
+ "count": 1,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Reading your record summary",
+ "labelKey": "coach.step.snapshot",
+ "phase": "fetch",
+ "round": 1,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Thinking…",
+ "labelKey": "insights.coach.activity.thinking",
+ "phase": "thinking",
+ "round": 1,
+ "status": "done",
+ },
+ {
+ "id": "a3",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4209,6 +5136,9 @@ exports[`coach chat golden transcripts > 16d coach.provider.none from the capabi
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -4254,6 +5184,9 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "isModuleEnabled",
"module": "workouts",
@@ -4274,7 +5207,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
],
},
{
- "amount": 1800,
+ "amount": 12890,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -4298,10 +5231,10 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -4312,12 +5245,33 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11054,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "That run held an average heart rate of 148 bpm.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
2400,
148,
@@ -4350,6 +5304,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -4357,15 +5312,28 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "That ",
"type": "token",
@@ -4408,6 +5376,16 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = `
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
2400,
148,
@@ -4468,6 +5446,9 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "annotate",
"metaKeys": [
@@ -4484,7 +5465,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
],
},
{
- "amount": 1800,
+ "amount": 12890,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -4508,10 +5489,10 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -4522,12 +5503,33 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11054,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "I can't look at that workout right now.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4558,6 +5560,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -4565,15 +5568,28 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "I ",
"type": "token",
@@ -4608,6 +5624,16 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4666,6 +5692,9 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -4673,7 +5702,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -4697,10 +5726,10 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -4711,7 +5740,18 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "event.setError",
@@ -4729,6 +5769,17 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"code": "coach.provider.unavailable",
"message": "coach.provider.unavailable",
@@ -4766,7 +5817,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
],
},
{
- "amount": 1800,
+ "amount": 13053,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -4790,10 +5841,10 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -4804,7 +5855,18 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11202,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1851,
},
{
"call": "annotate",
@@ -4820,6 +5882,16 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
"call": "appendMessage",
"content": "Your weight was 91 kg and […] kg at the start.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4851,6 +5923,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -4858,15 +5931,28 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -4913,6 +5999,16 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -4972,6 +6068,9 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -4979,7 +6078,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
],
},
{
- "amount": 1800,
+ "amount": 12892,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -5003,10 +6102,10 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 30000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -5017,12 +6116,33 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11056,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "Thanks, that helps.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5053,6 +6173,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -5060,15 +6181,28 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Thanks, ",
"type": "token",
@@ -5083,6 +6217,16 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5141,6 +6285,9 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -5148,7 +6295,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
],
},
{
- "amount": 1800,
+ "amount": 12917,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -5172,10 +6319,10 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
"window": "last30days",
},
"signal": true,
- "system": "c61eecc94d49:3280",
+ "system": "7c188ee61f98:5016",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -5186,12 +6333,33 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11078,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1839,
},
{
"call": "appendMessage",
"content": "Du läufst 30 km pro Woche, dein Blutdruck liegt bei 128 mmHg.",
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Schreibt die Antwort…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5222,6 +6390,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -5229,15 +6398,28 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Schreibt die Antwort…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Du ",
"type": "token",
@@ -5288,6 +6470,16 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self
},
{
"metricSource": {
+ "activity": [
+ {
+ "id": "a1",
+ "label": "Schreibt die Antwort…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5346,6 +6538,9 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
{
"call": "assertConsentForChain",
"chain": [
@@ -5353,7 +6548,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
],
},
{
- "amount": 1800,
+ "amount": 12891,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
@@ -5377,10 +6572,10 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
"window": "last30days",
},
"signal": true,
- "system": "4a4d00f5a344:3187",
+ "system": "37f58b1a7608:4923",
"temperature": 0.4,
"timeoutMs": 180000,
- "tools": 2,
+ "tools": 6,
},
{
"actual": 80,
@@ -5391,12 +6586,64 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
"cached": 0,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
- "reserved": 1800,
+ "reserved": 11055,
+ },
+ {
+ "actual": 0,
+ "attribution": {
+ "reservedOwner": "user",
+ "servedBy": null,
+ },
+ "cached": 0,
+ "call": "reconcileSpend",
+ "dateKey": "2026-09-26",
+ "reserved": 1836,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg.",
"metricSource": {
+ "activity": [
+ {
+ "count": 142,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Fetching Blood pressure, last 90 days…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": NaN,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Fetching Sleep, all time…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": NaN,
+ "status": "empty",
+ "stepRef": "s2",
+ },
+ {
+ "durationMs": 0,
+ "id": "a3",
+ "label": "Fetching Lab results, last 12 months…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": NaN,
+ "status": "failed",
+ "stepRef": "s3",
+ },
+ {
+ "id": "a4",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5474,6 +6721,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
{
"call": "annotate",
"metaKeys": [
+ "activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
@@ -5481,9 +6729,11 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
"promptVersion",
"provider",
"snapshotSent",
+ "stop",
"tokens",
"toolMode",
"toolsCalled",
+ "trailStored",
],
"name": "insights.coach.replied",
},
@@ -5502,6 +6752,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "id": "a1",
+ "label": "Fetching Blood pressure, last 90 days…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "running",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
{
"step": {
"domain": "sleep",
@@ -5514,6 +6776,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "id": "a2",
+ "label": "Fetching Sleep, all time…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "running",
+ "stepRef": "s2",
+ },
+ "type": "activity",
+ },
{
"step": {
"domain": "labs",
@@ -5526,6 +6800,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "id": "a3",
+ "label": "Fetching Lab results, last 12 months…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "running",
+ "stepRef": "s3",
+ },
+ "type": "activity",
+ },
{
"step": {
"count": 142,
@@ -5539,6 +6825,20 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "count": 142,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Fetching Blood pressure, last 90 days…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ "type": "activity",
+ },
{
"step": {
"domain": "sleep",
@@ -5552,6 +6852,19 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Fetching Sleep, all time…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "empty",
+ "stepRef": "s2",
+ },
+ "type": "activity",
+ },
{
"step": {
"domain": "labs",
@@ -5565,6 +6878,30 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
"type": "step",
},
+ {
+ "activity": {
+ "durationMs": 0,
+ "id": "a3",
+ "label": "Fetching Lab results, last 12 months…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "failed",
+ "stepRef": "s3",
+ },
+ "type": "activity",
+ },
+ {
+ "activity": {
+ "id": "a4",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "running",
+ },
+ "type": "activity",
+ },
{
"token": "Your ",
"type": "token",
@@ -5587,6 +6924,47 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em
},
{
"metricSource": {
+ "activity": [
+ {
+ "count": 142,
+ "durationMs": 0,
+ "id": "a1",
+ "label": "Fetching Blood pressure, last 90 days…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "done",
+ "stepRef": "s1",
+ },
+ {
+ "durationMs": 0,
+ "id": "a2",
+ "label": "Fetching Sleep, all time…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "empty",
+ "stepRef": "s2",
+ },
+ {
+ "durationMs": 0,
+ "id": "a3",
+ "label": "Fetching Lab results, last 12 months…",
+ "labelKey": "insights.coach.activity.fetching",
+ "phase": "fetch",
+ "round": null,
+ "status": "failed",
+ "stepRef": "s3",
+ },
+ {
+ "id": "a4",
+ "label": "Writing the answer…",
+ "labelKey": "insights.coach.activity.answer",
+ "phase": "answer",
+ "round": 1,
+ "status": "done",
+ },
+ ],
"groundedFigures": [
128,
82,
@@ -5779,6 +7157,9 @@ exports[`coach chat golden transcripts — refusals before the stream > R6 conse
{
"call": "storeDeterministicFacts",
},
+ {
+ "call": "enqueueCoachMemoryRefresh",
+ },
],
"contentType": null,
"frames": [],
diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts
index 25c9118ca..14f787db9 100644
--- a/src/app/api/insights/chat/__tests__/dialog-harness.ts
+++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts
@@ -458,7 +458,11 @@ export const modules = {
coachConversation: {
findFirst: async () => ({ id: CONVERSATION_ID }),
},
- coachMessage: { findMany: async () => latestRows() },
+ coachMessage: {
+ findMany: async () => latestRows(),
+ // v1.41 — the clarification brake's count of today's questions.
+ count: async () => 0,
+ },
measurement: {
groupBy: async (args: { where: { type: { in: string[] } } }) =>
measurementBounds(args),
@@ -473,7 +477,10 @@ export const modules = {
labResult: { aggregate: async () => EMPTY_AGGREGATE },
},
}),
- rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
+ rateLimit: () => ({
+ checkRateLimit: m.checkRateLimit,
+ refundRateLimit: async () => {},
+ }),
serverLocale: () => ({
resolveServerLocale: async (args: { override?: string }) =>
args.override ?? "en",
diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts
index 24eb8df5c..867f12e45 100644
--- a/src/app/api/insights/chat/__tests__/golden-harness.ts
+++ b/src/app/api/insights/chat/__tests__/golden-harness.ts
@@ -175,6 +175,8 @@ export function resetGolden(): void {
},
);
m.getEvent.mockReturnValue({
+ // A request event, so the per-request cache memoises as in production.
+ getKind: () => "request",
setError: (err: Error) =>
record("event.setError", { message: err.message }),
});
@@ -373,7 +375,26 @@ export async function runCoachToolLoop(args: Record) {
timeoutMs: args.timeoutMs,
signal: args.signal instanceof AbortSignal,
});
- return m.runCoachToolLoop(args);
+ const out = await m.runCoachToolLoop(args);
+ // The real loop settles each round on the turn's ledger as it returns;
+ // the scripted loop settles its whole cost as one round.
+ const spend = args.spend as
+ | {
+ settleRound(usage: {
+ tokens: number;
+ cachedTokens: number;
+ servedBy: string;
+ final: boolean;
+ }): Promise;
+ }
+ | undefined;
+ await spend?.settleRound({
+ tokens: out.totalTokens,
+ cachedTokens: out.cachedTokens,
+ servedBy: out.workingProviderType,
+ final: false,
+ });
+ return out;
}
export async function runStreamingRawCompletionWithFallback(
diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
index a0114a14d..1703970b6 100644
--- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
+++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts
@@ -63,9 +63,14 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate },
coachConversation: { findFirst: h.m.conversationFindFirst },
coachMessage: { findMany: h.m.coachMessageFindMany },
+ // v1.41 — the operator's reasoning controls; an untouched instance.
+ appSettings: { findUnique: vi.fn(async () => null) },
},
}));
-vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: h.m.checkRateLimit,
+ refundRateLimit: vi.fn(async () => {}),
+}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: h.m.resolveServerLocale,
}));
diff --git a/src/app/api/insights/chat/__tests__/route-activity.test.ts b/src/app/api/insights/chat/__tests__/route-activity.test.ts
new file mode 100644
index 000000000..92cb32fe5
--- /dev/null
+++ b/src/app/api/insights/chat/__tests__/route-activity.test.ts
@@ -0,0 +1,345 @@
+/**
+ * v1.41 — the live trail and the dialog of a turn through the real route,
+ * pipeline and loop, with a scripted provider:
+ *
+ * - frame order: the trail and the steps first (thinking before the
+ * provider is called, each fetch, the digest), interim tables as soon as
+ * they are read, the answer entry before the first token, then the reply
+ * frames as before;
+ * - the stored trail carries the model text, the provenance only the
+ * structure;
+ * - a question asked through the tool ends the turn with its choices;
+ * - a person may run two turns at once, not three;
+ * - a tapped decision is answered without a model, and a stale one goes to
+ * the model as an ordinary message.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const h = await vi.hoisted(
+ () => import("@/app/api/insights/chat/__tests__/dialog-harness"),
+);
+const contract = vi.hoisted(() => ({
+ decideFactProposal: vi.fn(),
+ decidePlanProposal: vi.fn(),
+}));
+
+vi.mock("@/lib/api-handler", () => h.modules.apiHandler());
+vi.mock("@/lib/api-response", () => h.modules.apiResponse());
+vi.mock("@/lib/modules/gate", () => h.modules.gate());
+vi.mock("@/lib/ai/capabilities/gate", () => h.modules.capabilities());
+vi.mock("@/lib/logging/context", () => h.modules.logging());
+vi.mock("@/lib/auth/audit", () => h.modules.audit());
+vi.mock("@/lib/db", () => h.modules.db());
+vi.mock("@/lib/rate-limit", () => h.modules.rateLimit());
+vi.mock("@/lib/i18n/server-locale", () => h.modules.serverLocale());
+vi.mock("@/lib/ai/provider-runner", () => h.modules.providerRunner());
+vi.mock("@/lib/ai/provider", () => h.modules.provider());
+vi.mock("@/lib/ai/consent-guard", () => h.modules.consent());
+vi.mock("@/lib/ai/coach/persistence", () => h.modules.persistence());
+vi.mock("@/lib/ai/coach/coach-memory-shared", () => h.modules.memory());
+vi.mock("@/lib/ai/coach/facts", () => h.modules.facts());
+vi.mock("@/lib/ai/coach/budget", () => h.modules.budget());
+vi.mock("@/lib/ai/coach/about-me", () => h.modules.aboutMe());
+vi.mock("@/lib/ai/coach/snapshot", () => h.modules.snapshot());
+vi.mock("@/lib/medications/scheduled-doses", () => h.modules.scheduledDoses());
+vi.mock("@/lib/ai/coach/workout-evidence-builder", () =>
+ h.modules.workoutEvidence(),
+);
+vi.mock("@/lib/ai/coach/suggest-gate", () => h.modules.suggestGate());
+vi.mock("@/lib/monitoring-settings", () => h.modules.glitchtipSettings());
+vi.mock("@/lib/monitoring/glitchtip", () => h.modules.glitchtip());
+vi.mock("@/lib/tz/resolver", () => h.modules.timezone());
+vi.mock("@/lib/measurements/daily-series-read", () => h.modules.dailySeries());
+vi.mock("@/lib/rollups/measurement-read", () => h.modules.sourcePriority());
+vi.mock("@/lib/ai/coach/bytes-codec", () => h.modules.bytesCodec());
+vi.mock("@/lib/ai/coach/tools/inventory", async (importOriginal) => ({
+ ...(await importOriginal
) : null}
- {/* v1.39.4 — mounted empty before any question, so the question is
- announced when it arrives (a live region that mounts with its
- content is not heard). Visually hidden, so it takes no room in the
- stack; the card below carries the choices. */}
-
- {openClarification ? (
-
- ) : null}
}
composer={composerStack}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index 49fc18139..a6813f540 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -372,7 +372,7 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
// Read-only rather than disabled while a reply streams: a disabled
- // field drops focus, and a follow-up chip or a clarification choice
+ // field drops focus, and a tapped reply pill
// hands focus here right as the turn starts.
readOnly={disabled}
aria-disabled={disabled || undefined}
diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx
index 8c73b35a1..3c6a3d543 100644
--- a/src/components/insights/coach-panel/coach-model-picker.tsx
+++ b/src/components/insights/coach-panel/coach-model-picker.tsx
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
-import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
@@ -16,7 +15,6 @@ import {
uiToLegacyProviderEnum,
type ProviderChainData,
type ProviderType,
- type ReasoningEffort,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
@@ -29,7 +27,9 @@ import {
/**
* The quick model choice in the Coach's settings overlay: which provider
- * answers, which model it uses, and how hard a reasoning model thinks.
+ * answers and which model it uses. How hard the Coach thinks is the Coach's
+ * own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
+ * reasoning select stays in Settings → AI for everything else.
*
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
@@ -38,7 +38,7 @@ import {
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
- * - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
+ * - Model: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
@@ -127,29 +127,6 @@ export function modelControlFor(
return { kind: "managed" };
}
-/** The reasoning setting the active provider carries, if any. */
-export function reasoningFieldFor(
- active: ProviderType | null,
- userProvider: UserAIProvider | null | undefined,
-): {
- field: "localReasoningEffort" | "compatReasoningEffort";
- value: ReasoningEffort | null;
-} | null {
- if (active === "local") {
- return {
- field: "localReasoningEffort",
- value: userProvider?.localReasoningEffort ?? null,
- };
- }
- if (active === "openai-compatible") {
- return {
- field: "compatReasoningEffort",
- value: userProvider?.compatReasoningEffort ?? null,
- };
- }
- return null;
-}
-
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -212,7 +189,6 @@ export function CoachModelPicker() {
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
- const reasoning = reasoningFieldFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
@@ -339,16 +315,6 @@ export function CoachModelPicker() {
)}
- {reasoning ? (
- patchProvider.mutate({ [reasoning.field]: next })}
- />
- ) : null}
-
{busy ? (
{t("insights.coach.frame.saving")}
diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx
new file mode 100644
index 000000000..8c97d8600
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+/**
+ * v1.41 — how hard the Coach thinks before it answers: one select, Off / Low
+ * / Medium / High, Medium by default.
+ *
+ * The person's own choice lives in `coachPrefsJson.reasoning`. What the
+ * server resolved from it rides `/api/auth/me` as `coachReasoning`: whether
+ * the operator allows reasoning at all, the highest level they allow, and
+ * whether "off" is really off on the active provider. The field renders from
+ * that and never recomputes it:
+ * - the operator switched reasoning off: the select is locked and one
+ * sentence says so;
+ * - a level above the operator's cap is offered but disabled, with
+ * "(limited by the admin)";
+ * - where the provider cannot switch thinking off, "Off" reads "Minimal".
+ * A server older than v1.41 sends no block; the field then reads as allowed,
+ * uncapped and really off.
+ *
+ * Two homes, one field: the Coach's quick settings (saves on change) and
+ * Settings → Coach (part of that card's draft and save).
+ */
+import { useQueryClient } from "@tanstack/react-query";
+
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useTranslations } from "@/lib/i18n/context";
+import { useCoachReasoning } from "@/hooks/use-ai-capability";
+import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ DEFAULT_REASONING_LEVEL,
+ REASONING_LEVELS,
+ REASONING_LEVEL_LABEL_KEYS,
+ REASONING_SETTING_KEYS,
+ isReasoningLevel,
+ type ReasoningLevel,
+} from "@/lib/ai/reasoning/levels";
+import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
+import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
+
+export type { CoachReasoningState };
+
+/**
+ * The block as the field reads it: the server's answer, or for a server that
+ * does not publish one (older than v1.41) "allowed, uncapped, really off" at
+ * the default level.
+ */
+export function reasoningStateOrDefault(
+ state: CoachReasoningState | null | undefined,
+): CoachReasoningState {
+ return (
+ state ?? {
+ level: DEFAULT_REASONING_LEVEL,
+ preference: DEFAULT_REASONING_LEVEL,
+ maxLevel: "high",
+ available: true,
+ offIsReal: true,
+ source: "user",
+ }
+ );
+}
+
+/** True when the operator switched reasoning off: the field is locked. */
+export function reasoningLocked(state: CoachReasoningState): boolean {
+ return state.maxLevel === "off" || state.source === "admin_off";
+}
+
+export interface ReasoningOption {
+ value: ReasoningLevel;
+ label: string;
+ disabled: boolean;
+}
+
+/** The four options in order, labelled and capped for this account. */
+export function reasoningOptions(
+ state: CoachReasoningState,
+ t: (key: string) => string,
+): ReasoningOption[] {
+ const locked = reasoningLocked(state);
+ const cap = REASONING_LEVELS.indexOf(state.maxLevel);
+ return REASONING_LEVELS.map((value, index) => {
+ const base = t(
+ value === "off" && !state.offIsReal
+ ? REASONING_LEVEL_LABEL_KEYS.minimal
+ : REASONING_LEVEL_LABEL_KEYS[value],
+ );
+ // Above the cap is "(limited by the admin)"; while reasoning is off the
+ // one sentence under the field says it, not every option.
+ const capped = !locked && index > cap;
+ return {
+ value,
+ label: capped ? `${base} ${t(REASONING_SETTING_KEYS.capped)}` : base,
+ disabled: capped,
+ };
+ });
+}
+
+/**
+ * The level the select shows: the person's choice, lowered to the cap when
+ * it is above it (that is what runs), and the resolved level while the
+ * operator has reasoning off.
+ */
+export function shownReasoningLevel(
+ choice: ReasoningLevel,
+ state: CoachReasoningState,
+): ReasoningLevel {
+ if (reasoningLocked(state)) return state.level;
+ return REASONING_LEVELS.indexOf(choice) >
+ REASONING_LEVELS.indexOf(state.maxLevel)
+ ? state.maxLevel
+ : choice;
+}
+
+export interface CoachReasoningSelectProps {
+ id: string;
+ value: ReasoningLevel;
+ state: CoachReasoningState;
+ /** Locks the select while a write is in flight. */
+ busy?: boolean;
+ /**
+ * `sheet` (default): the quick settings' stacked field, as wide as the
+ * sheet. `card`: Settings → Coach, where it sits in the grid beside Tone
+ * and Reply length and takes their small label and content-wide select.
+ */
+ variant?: "sheet" | "card";
+ onChange: (next: ReasoningLevel) => void;
+}
+
+/** The field itself, without data: what the tests render. */
+export function CoachReasoningSelect({
+ id,
+ value,
+ state,
+ busy = false,
+ variant = "sheet",
+ onChange,
+}: CoachReasoningSelectProps) {
+ const { t } = useTranslations();
+ const options = reasoningOptions(state, t);
+ const locked = reasoningLocked(state);
+ const card = variant === "card";
+ return (
+
+
+ {
+ const next = e.target.value;
+ if (isReasoningLevel(next) && next !== value) onChange(next);
+ }}
+ >
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+/** The quick-settings field: reads the saved choice and saves on change. */
+export function CoachReasoningField({ id }: { id: string }) {
+ const queryClient = useQueryClient();
+ const reasoning = useCoachReasoning();
+ const prefs = useCoachPrefs();
+ const save = useSaveCoachPrefs({
+ // The resolved block on `/me` follows the saved choice.
+ onSuccess: () =>
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }),
+ });
+ if (!prefs.data) return null;
+ const current = prefs.data;
+ const state = reasoningStateOrDefault(reasoning);
+ return (
+ save.mutate({ ...current, reasoning })}
+ />
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
index b0a855961..b1ad56296 100644
--- a/src/components/insights/coach-panel/coach-results.tsx
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -115,6 +115,32 @@ function useReusedLine(result: CoachResultMeta): string | null {
: t(COACH_RESULT_UI_KEYS.reusedFromUndated);
}
+/** `displayed` per ref, from a message's result metadata. */
+function displayedByRef(
+ metas: ReadonlyArray>,
+): Map {
+ return new Map(metas.map((meta) => [meta.ref, meta.displayed]));
+}
+
+/**
+ * The live tables with `displayed` taken from the provenance where it names
+ * them: an interim table is sent before the answer decides.
+ */
+export function liveWithProvenance<
+ T extends Pick,
+>(
+ live: readonly T[],
+ metas: ReadonlyArray>,
+): T[] {
+ const displayed = displayedByRef(metas);
+ return live.map((table) => {
+ const flag = displayed.get(table.ref);
+ return flag === undefined || flag === table.displayed
+ ? table
+ : { ...table, displayed: flag };
+ });
+}
+
/** How many tables sit in a section, from the metadata alone. */
export function countResultsInSection(
metas: ReadonlyArray>,
@@ -147,8 +173,11 @@ function sectionItems(args: {
const inSection = (displayed: boolean) =>
args.section === "displayed" ? displayed : !displayed;
if (args.live && args.live.length > 0) {
+ // v1.41 — a table that arrived while the turn still ran learns whether
+ // the answer points at it from the provenance, which comes later.
+ const displayed = displayedByRef(args.metas);
return args.live
- .filter((table) => inSection(table.displayed))
+ .filter((table) => inSection(displayed.get(table.ref) ?? table.displayed))
.map((table) => ({ ref: table.ref, table }));
}
if (!args.fetched) return [];
@@ -174,7 +203,7 @@ export function CoachResults({
const { t } = useTranslations();
const hasLive = (live?.length ?? 0) > 0;
const expected = hasLive
- ? countResultsInSection(live ?? [], section)
+ ? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
: countResultsInSection(metas, section);
const { ref, results, isError, refetch } = useCoachMessageResults({
conversationId,
@@ -236,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
-function CoachResultView({
+export function CoachResultView({
result,
chartFirst,
}: {
diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx
index 2a844cbb9..2b1cd7a70 100644
--- a/src/components/insights/coach-panel/coach-settings-body.tsx
+++ b/src/components/insights/coach-panel/coach-settings-body.tsx
@@ -2,27 +2,63 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
-import { Cpu } from "lucide-react";
+import { Brain, ChevronRight, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import { COACH_MEMORY_LIST_KEYS } from "@/lib/ai/coach/memory/shared";
+
import { CoachModelPicker } from "./coach-model-picker";
+import { CoachReasoningField } from "./coach-reasoning-field";
+import { useCoachFacts } from "./use-coach-facts";
import { SourcesRail } from "./sources-rail";
/**
* The content of the Coach's quick settings (see `coach-settings-overlay`):
- * the model choice, "What I can see" (the existing sources rail, wired to the
- * saved Coach preferences: data areas and how far back the Coach looks) and
- * a way on to the full AI settings.
+ * the model choice and how hard the Coach thinks, a way to what it remembers,
+ * "What I can see" (the existing sources rail, wired to the saved Coach
+ * preferences: data areas and how far back the Coach looks) and a way on to
+ * the full AI settings.
*
* `focusData` lands focus on the "What I can see" section when the body
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
* in the overlay because the body loads on demand and may arrive after the
- * popover or sheet has already placed focus.
+ * popover or sheet has already placed focus. The section draws no focus
+ * ring: it is where the link lands, not a control, and a ring drawn on every
+ * deep link reads as a highlight nobody asked for.
*/
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
+/** Where the memory list lives: Settings → Coach, its memory card. */
+export const COACH_MEMORY_HREF = "/settings/coach#coach-memory";
+
+/** "Memory (8)": a link to the list, with how much it holds. */
+function CoachMemoryLink() {
+ const { t } = useTranslations();
+ const facts = useCoachFacts();
+ const count = facts.data?.length ?? 0;
+ return (
+
+
+
+ {t(COACH_MEMORY_LIST_KEYS.link, { count })}
+
+
+
+ );
+}
+
export function CoachSettingsBody({
focusData = false,
}: {
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
{t("insights.coach.frame.modelSection")}
+
+
diff --git a/src/components/insights/coach-panel/composer-focus.ts b/src/components/insights/coach-panel/composer-focus.ts
index afcecf39d..afb0e0211 100644
--- a/src/components/insights/coach-panel/composer-focus.ts
+++ b/src/components/insights/coach-panel/composer-focus.ts
@@ -1,9 +1,9 @@
/**
* v1.39.4 — the Coach composer's id, and handing focus to it.
*
- * A follow-up chip or a clarification choice sends a message and then goes
- * away (chips hide while the turn runs, choices disable), which would drop
- * keyboard and screen-reader focus to the page. Focus moves to the composer
+ * A reply pill (a follow-up, a clarifying choice, a memory or plan
+ * decision) sends a message and then goes away (pills hide while the turn
+ * runs), which would drop keyboard and screen-reader focus to the page. Focus moves to the composer
* instead: that is where the person continues. The composer stays focusable
* while a reply streams (it is read-only then, not disabled).
*/
diff --git a/src/components/insights/coach-panel/focus-ring.ts b/src/components/insights/coach-panel/focus-ring.ts
new file mode 100644
index 000000000..c96f03ae3
--- /dev/null
+++ b/src/components/insights/coach-panel/focus-ring.ts
@@ -0,0 +1,15 @@
+/**
+ * The Coach's keyboard focus mark: a solid ring in the neutral text-field
+ * focus tone (`--input-focus`, at least 3:1 on the background in both
+ * themes), never the purple `--ring`. It shows on keyboard focus only
+ * (`:focus-visible`), so a tap or a click draws nothing.
+ *
+ * On a `Button` it also replaces the primitive's ring colour and width and
+ * keeps its border the resting one instead of the purple focus border (`cn`
+ * merges the later classes over the variant's), so a pill or an inline link
+ * in the thread marks focus the same way as the hand-built controls beside
+ * it. `coach-focus-ring-guard.test.ts` keeps the purple ring out of the
+ * Coach panel.
+ */
+export const COACH_FOCUS_RING =
+ "outline-none focus-visible:ring-2 focus-visible:ring-input-focus focus-visible:border-border";
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
index 64e5561c6..d525c13b2 100644
--- a/src/components/insights/coach-panel/follow-up-chips.tsx
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -2,24 +2,21 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
- * They sit inside the answer's own column, on the bubble's left edge and at
- * the answer's text size, so they read as part of the reply rather than as
- * thread chrome. A tap sends the chip's label as the message with
- * `followUp: { messageId, id }`; the server resolves what the chip asks for.
+ * v1.41 — a thin adapter onto `SuggestedReplies`, the one pattern every
+ * tappable reply in the thread uses (follow-ups, clarifying choices, memory
+ * and plan decisions).
*
+ * A tap sends the chip's label as the message with
+ * `followUp: { messageId, id }`; the server resolves what the chip asks for.
* The label is the server's rendering in the request locale. It is not
* re-rendered from `labelKey` here: a related-metric chip names its metric
- * in the label, and the key alone does not carry it. Hidden while a turn is
- * in flight, so a chip can never be tapped against a reply that is about to
- * stop being the latest. The tapped chip goes away with the rest, so focus
- * moves to the composer.
+ * in the label, and the key alone does not carry it.
*/
-import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
-import { focusCoachComposer } from "./composer-focus";
+import { SuggestedReplies } from "./suggested-replies";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
@@ -40,36 +37,21 @@ export function CoachFollowUpChips({
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
- const chips = followUps.slice(0, MAX_CHIPS);
- if (disabled || chips.length === 0) return null;
- return (
-