feat(coach): lay down the reasoning, trail and memory contract

Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 18:39:50 +02:00
1 parent f269100b01
commit 210becb28b
38 files changed
+3308 -57

No files matched your search

+773 -12
View File
@@ -25979,6 +25979,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 +26676,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
@@ -34972,6 +35016,14 @@ components:
type: boolean
assistantDocumentAiEnabled:
type: boolean
aiReasoningEnabled:
type: boolean
aiReasoningMaxEffort:
type: string
enum:
- low
- medium
- high
moduleAvailabilityJson:
anyOf:
- {}
@@ -38866,6 +38918,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 +39093,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 +48958,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 +52249,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 +52302,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 +52334,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: <json>\\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 +52579,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 +52677,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 +52745,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 +52871,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 +52938,7 @@ components:
- type: string
enum:
- line
- compare
- bar
- histogram
- type: "null"
@@ -52807,7 +52955,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 +52994,7 @@ components:
- as_table
- related_metric
- continue
- change_assumption
labelKey:
type: string
label:
@@ -52856,7 +53005,7 @@ components:
properties:
ref:
type: string
pattern: ^(?:r[1-6])?$
pattern: ^(?:r[1-8])?$
domain:
$ref: "#/components/schemas/CoachStepDomain"
window:
@@ -52891,6 +53040,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 +53143,11 @@ components:
enum:
- metric
- window
- comparison
- goal
- anchor
- context
description: "`comparison`, `goal` and `anchor` since v1.41."
choices:
type: array
items:
@@ -52976,7 +53214,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 +53241,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 +53253,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 +53609,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 +53676,7 @@ components:
- type: string
enum:
- line
- compare
- bar
- histogram
- type: "null"
@@ -53111,7 +53693,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 +53766,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 +53871,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 +54040,7 @@ components:
properties:
ref:
type: string
pattern: ^r[1-6]$
pattern: ^r[1-8]$
withheld:
type: string
enum:
@@ -78175,6 +78926,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
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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."
},
"plan": {
"accept": "Plan übernehmen",
"decline": "Nicht jetzt",
"confirmed": "Ist notiert. Ich schaue in {days} Tagen mit dir drauf."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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."
},
"plan": {
"accept": "Take on this plan",
"decline": "Not now",
"confirmed": "Noted. I'll check in with you in {days} days."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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é."
},
"plan": {
"accept": "Adoptar este plan",
"decline": "Ahora no",
"confirmed": "Anotado. Lo revisaremos juntos dentro de {days} días."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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."
},
"plan": {
"accept": "Adopter ce plan",
"decline": "Pas maintenant",
"confirmed": "C'est noté. On fait le point ensemble dans {days} jours."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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ò."
},
"plan": {
"accept": "Adotta questo piano",
"decline": "Non ora",
"confirmed": "Annotato. Ne riparliamo insieme tra {days} giorni."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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": "알겠어요, 기억해 둘게요."
},
"plan": {
"accept": "이 계획 시작하기",
"decline": "나중에",
"confirmed": "기록했어요. {days}일 뒤에 함께 살펴볼게요."
},
"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}분 전",
@@ -6008,7 +6079,8 @@
"forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.",
"clearedToast": "저장된 정보 {count}개를 잊었어요.",
"clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.",
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요."
"detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.",
"categoryMedication": "약"
},
"coachReminders": {
"title": "내 알림",
@@ -6663,6 +6735,13 @@
"savedOn": "코치를 켰어요.",
"savedOff": "코치를 숨겼어요.",
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
},
"memory": {
"edit": "편집",
"source": {
"user": "내가 알려 줌",
"coach": "코치가 기억함"
}
}
},
"integrationRedirectGuide": {
@@ -7137,6 +7216,10 @@
"documentAi": {
"title": "문서 읽기",
"description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다."
},
"reasoning": {
"enabled": "깊은 생각 허용",
"max": "최대 생각 깊이"
}
},
"modules": {
@@ -9933,7 +10016,8 @@
"relatedMetric": "함께 보기: {metric}",
"continue": "계속 찾아보기",
"settingLabel": "후속 질문 추천",
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요."
"settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.",
"changeAssumption": "대신: {value}"
},
"clarify": {
"cardLabel": "코치가 질문이 있어요",
+87 -3
View File
@@ -3146,7 +3146,78 @@
"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."
},
"plan": {
"accept": "Przyjmij ten plan",
"decline": "Nie teraz",
"confirmed": "Zanotowane. Wrócimy do tego razem za {days} dni."
},
"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",
@@ -6008,7 +6079,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",
@@ -6663,6 +6735,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 +7237,10 @@
"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"
}
},
"modules": {
@@ -9933,7 +10016,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",
@@ -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';
@@ -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;
@@ -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;
+30
View File
@@ -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)
+11 -2
View File
@@ -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",
@@ -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(
@@ -122,6 +122,9 @@ export function CoachResultChart({
if (spec.kind === "histogram") {
return <HistogramChart result={result} spec={spec} label={label} />;
}
// v1.41 — the contract is in place; no table carries this kind yet, and the
// table view stays available until the chart lands.
if (spec.kind === "compare") return null;
if (spec.kind === "bar" && spec.orientation === "horizontal") {
return (
<CategoryBarChart
@@ -0,0 +1,152 @@
/**
* The v1.41 message keys and the seven bundles agree, both ways.
*
* The trail, memory, plan, reasoning and reply keys were laid down before
* the code that renders them, so the catalogs in the contract modules are
* their only reference until then. Forward: every key a catalog can produce
* resolves in every locale, with the same placeholders as English. Reverse:
* every leaf under the namespaces these catalogs own is one they can
* produce, so a string cannot sit in the bundles with nothing able to reach
* it.
*
* Checked by breaking it: deleting `insights.coach.activity.answer` from
* `pl.json` fails the forward test by locale and key; renaming `{fact}` in
* the German `insights.coach.memory.saved` fails the placeholder test; adding
* an unused leaf under `insights.coach.reasoning` fails the reverse test.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
COACH_ACTIVITY_KEYS,
COACH_ACTIVITY_STOP_KEYS,
activityAreasKey,
activityDigestDoneKey,
activityDigestKey,
activityMemoryKey,
activitySummaryKey,
} from "@/lib/ai/coach/activity/contract";
import {
COACH_ASSUMPTION_KEYS,
COACH_CHART_COMPARE_KEYS,
COACH_SUGGESTED_REPLIES_KEYS,
interimLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
COACH_MEMORY_KEYS,
COACH_MEMORY_LIST_KEYS,
COACH_PLAN_KEYS,
} from "@/lib/ai/coach/memory/contract";
import {
REASONING_ADMIN_KEYS,
REASONING_LEVEL_LABEL_KEYS,
REASONING_SETTING_KEYS,
} from "@/lib/ai/reasoning/levels";
import { locales, type Locale } from "@/lib/i18n/config";
const ROOT = join(__dirname, "../../../../..");
function strings(locale: Locale): Map<string, unknown> {
const out = new Map<string, unknown>();
const walk = (node: unknown, prefix: string) => {
if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
walk(value, prefix ? `${prefix}.${key}` : key);
}
} else {
out.set(prefix, node);
}
};
walk(
JSON.parse(
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
) as unknown,
"",
);
return out;
}
/** Counts that land on every plural tier some locale uses. */
const COUNTS = [0, 1, 2, 3, 5, 12, 22];
function catalogKeys(): Set<string> {
const keys = new Set<string>([
...Object.values(COACH_ACTIVITY_KEYS),
...Object.values(COACH_ACTIVITY_STOP_KEYS),
...Object.values(COACH_MEMORY_KEYS),
...Object.values(COACH_PLAN_KEYS),
...Object.values(COACH_MEMORY_LIST_KEYS),
...Object.values(COACH_ASSUMPTION_KEYS),
...Object.values(COACH_SUGGESTED_REPLIES_KEYS),
...Object.values(COACH_CHART_COMPARE_KEYS),
...Object.values(REASONING_LEVEL_LABEL_KEYS),
...Object.values(REASONING_SETTING_KEYS),
...Object.values(REASONING_ADMIN_KEYS),
]);
for (const locale of locales) {
for (const count of COUNTS) {
keys.add(activityDigestKey(count, locale));
keys.add(activityDigestDoneKey(count, locale));
keys.add(activityAreasKey(count, locale));
keys.add(activityMemoryKey(count, locale));
keys.add(activitySummaryKey(count, locale));
keys.add(interimLabelKey(count, locale));
}
}
return keys;
}
const OWNED = [
"insights.coach.activity",
"insights.coach.memory",
"insights.coach.plan",
"insights.coach.assumption",
"insights.coach.suggestedReplies",
"insights.coach.interim",
"insights.coach.chart",
"insights.coach.reasoning",
"settings.coach.memory",
"admin.assistant.reasoning",
];
const placeholders = (text: string) =>
[...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
describe("v1.41 contract keys", () => {
const keys = catalogKeys();
const en = strings("en");
it("produces the whole catalog", () => {
// Every key laid down for the release but the `change_assumption` chip
// label, which the dialog catalog's own test covers.
expect(keys.size).toBe(57);
});
it.each(locales)("every catalog key resolves in %s", (locale) => {
const bundle = strings(locale);
const missing = [...keys].filter(
(key) => typeof bundle.get(key) !== "string" || bundle.get(key) === "",
);
expect(missing).toEqual([]);
});
it.each(locales)("%s keeps the English placeholders", (locale) => {
const bundle = strings(locale);
const drifted = [...keys].filter(
(key) =>
placeholders(String(bundle.get(key))).join() !==
placeholders(String(en.get(key))).join(),
);
expect(drifted).toEqual([]);
});
it("every leaf under the owned namespaces is reachable from a catalog", () => {
const owned = [...en.keys()].filter((key) =>
OWNED.some((ns) => key.startsWith(`${ns}.`)),
);
expect(owned.length).toBeGreaterThan(40);
expect(owned.filter((key) => !keys.has(key))).toEqual([]);
});
});
@@ -5,11 +5,15 @@
*/
import { describe, expect, it } from "vitest";
import { ACTIVITY_MAX_ENTRIES } from "../activity/contract";
import {
coachActivityMetaSchema,
coachClarificationSchema,
coachFollowUpSchema,
coachMemoryNoteMetaSchema,
coachResultMetaSchema,
coachStepSchema,
coachTrailSchema,
} from "../stream-events";
const STEP = {
@@ -107,3 +111,86 @@ describe("stored dialog ids", () => {
).toBe(false);
});
});
describe("v1.41 ids", () => {
const ACTIVITY = {
id: "a1",
phase: "fetch",
status: "done",
round: 1,
labelKey: "insights.coach.activity.fetching",
label: "Fetching blood pressure, last 30 days…",
stepRef: "s7",
};
it("takes eight tables a message and no ninth", () => {
expect(
coachResultMetaSchema.safeParse({ ...META, ref: "r8" }).success,
).toBe(true);
expect(
coachResultMetaSchema.safeParse({ ...META, ref: "r9" }).success,
).toBe(false);
expect(
coachFollowUpSchema.safeParse({
...CHIP,
anchor: { ref: "r8", domain: "bp" },
}).success,
).toBe(true);
});
it("numbers trail entries a1 up to the cap", () => {
expect(coachActivityMetaSchema.safeParse(ACTIVITY).success).toBe(true);
expect(
coachActivityMetaSchema.safeParse({
...ACTIVITY,
id: `a${ACTIVITY_MAX_ENTRIES}`,
}).success,
).toBe(true);
expect(
coachActivityMetaSchema.safeParse({
...ACTIVITY,
id: `a${ACTIVITY_MAX_ENTRIES + 1}`,
}).success,
).toBe(false);
});
it("refuses anything that could carry text into a prompt", () => {
expect(
coachActivityMetaSchema.safeParse({ ...ACTIVITY, id: PLANT }).success,
).toBe(false);
expect(
coachActivityMetaSchema.safeParse({ ...ACTIVITY, stepRef: PLANT })
.success,
).toBe(false);
expect(
coachTrailSchema.safeParse({ entries: [{ id: PLANT, title: "t" }] })
.success,
).toBe(false);
expect(
coachMemoryNoteMetaSchema.safeParse({
proposal: false,
factId: PLANT,
category: "goal",
}).success,
).toBe(false);
expect(
coachClarificationSchema.safeParse({
kind: "goal",
freeText: true,
assumption: PLANT,
choices: [
{ id: "c1", labelKey: "k", label: "l", value: { goal: "plan1" } },
],
}).success,
).toBe(false);
expect(
coachClarificationSchema.safeParse({
kind: "goal",
freeText: true,
choices: [
{ id: "c1", labelKey: "k", label: "l", value: { goal: PLANT } },
],
}).success,
).toBe(false);
});
});
@@ -15,26 +15,44 @@ import { describe, expectTypeOf, it } from "vitest";
import type { z } from "zod/v4";
import type {
CoachActivity,
CoachActivityMeta,
CoachAssumption,
CoachClarification,
CoachFollowUp,
CoachMemoryNote,
CoachMemoryNoteMeta,
CoachPlanProposal,
CoachPlanProposalMeta,
CoachMethod,
CoachProvenance,
CoachResultEntry,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachStop,
CoachStreamEvent,
CoachTrail,
} from "@/lib/ai/coach/types";
import {
coachActivityMetaSchema,
coachActivitySchema,
coachAssumptionSchema,
coachClarificationSchema,
coachFollowUpSchema,
coachMemoryNoteMetaSchema,
coachMemoryNoteSchema,
coachPlanProposalMetaSchema,
coachPlanProposalSchema,
coachMethodSchema,
coachProvenanceSchema,
coachResultEntrySchema,
coachResultMetaSchema,
coachResultTableSchema,
coachStepSchema,
coachStopSchema,
coachStreamEventSchema,
coachTrailSchema,
} from "@/lib/ai/coach/stream-events";
describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
@@ -68,4 +86,32 @@ describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
z.infer<typeof coachProvenanceSchema>
>().toEqualTypeOf<CoachProvenance>();
});
it("the v1.41 trail, memory and plan shapes", () => {
expectTypeOf<
z.infer<typeof coachActivitySchema>
>().toEqualTypeOf<CoachActivity>();
expectTypeOf<
z.infer<typeof coachActivityMetaSchema>
>().toEqualTypeOf<CoachActivityMeta>();
expectTypeOf<z.infer<typeof coachStopSchema>>().toEqualTypeOf<CoachStop>();
expectTypeOf<
z.infer<typeof coachAssumptionSchema>
>().toEqualTypeOf<CoachAssumption>();
expectTypeOf<
z.infer<typeof coachMemoryNoteSchema>
>().toEqualTypeOf<CoachMemoryNote>();
expectTypeOf<
z.infer<typeof coachMemoryNoteMetaSchema>
>().toEqualTypeOf<CoachMemoryNoteMeta>();
expectTypeOf<
z.infer<typeof coachPlanProposalSchema>
>().toEqualTypeOf<CoachPlanProposal>();
expectTypeOf<
z.infer<typeof coachPlanProposalMetaSchema>
>().toEqualTypeOf<CoachPlanProposalMeta>();
expectTypeOf<
z.infer<typeof coachTrailSchema>
>().toEqualTypeOf<CoachTrail>();
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
ACTIVITY_TEXT_MAX_CHARS,
ACTIVITY_TITLE_MAX_CHARS,
TRAIL_MAX_BYTES,
activitySummaryKey,
createNoopActivityRecorder,
} from "../contract";
describe("activity contract", () => {
it("the no-op recorder sends and stores nothing", () => {
const recorder = createNoopActivityRecorder();
const id = recorder.start({
phase: "thinking",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
});
recorder.update(id, { title: "**Checking the trend**" });
recorder.finish(id, "done", { text: "Looked at the last month." });
expect(recorder.meta()).toEqual([]);
expect(recorder.trail()).toBeNull();
});
it("a full trail of model text fits its stored bound", () => {
// 99 entries can never all carry text: the bound is what a turn stores,
// and the recorder trims to it. A dozen rounds of full text must fit.
const twelveRounds = JSON.stringify({
entries: Array.from({ length: 12 }, (_, i) => ({
id: `a${i + 1}`,
title: "t".repeat(ACTIVITY_TITLE_MAX_CHARS),
text: "x".repeat(ACTIVITY_TEXT_MAX_CHARS),
})),
});
expect(new TextEncoder().encode(twelveRounds).byteLength).toBeLessThan(
TRAIL_MAX_BYTES,
);
});
it("says nothing about lookups on a turn that made none", () => {
expect(activitySummaryKey(0, "en")).toBe(
"insights.coach.activity.summaryNoLookups",
);
expect(activitySummaryKey(1, "en")).toBe(
"insights.coach.activity.summaryOne",
);
expect(activitySummaryKey(3, "pl")).toBe(
"insights.coach.activity.summaryFew",
);
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* v1.41 — the contract of a turn's live trail.
*
* The loop reports what it is doing through an {@link ActivityRecorder}: it
* opens an entry before each phase starts (the thinking round before the
* provider call, each fetch before its tool runs), updates it while it runs
* (a reasoning title arriving, a count) and closes it. The recorder sends the
* `activity` frames, screens model text before it goes anywhere, and at the
* end hands back the plaintext metadata for `metricSource.activity` and the
* model text for `coach_messages.trail_encrypted`.
*
* Labels are always catalog text rendered on the server; model text only ever
* rides `title` and `text`. A `checkpoint` entry (assistant text that came
* beside tool calls) is labelled with the thinking key and carries the
* screened sentence as its title.
*
* The no-op recorder is what every caller holds until the real one is wired:
* it sends nothing and stores nothing, so a turn behaves exactly as before.
*
* Client-safe: no server import.
*/
import type {
CoachActivityMeta,
CoachActivityPhase,
CoachStepStatus,
CoachStopReason,
CoachTrail,
} from "@/lib/ai/coach/types";
import type { Locale } from "@/lib/i18n/config";
import { pluralKey } from "@/lib/i18n/plural";
/** The stored trail text of one message, as UTF-8 JSON, before encryption. */
export const TRAIL_MAX_BYTES = 16 * 1024;
/** A reasoning title or checkpoint sentence, after screening. */
export const ACTIVITY_TITLE_MAX_CHARS = 80;
/** A reasoning summary per round, after screening. */
export const ACTIVITY_TEXT_MAX_CHARS = 400;
/** `a1`..`a99`: the 100th entry of a turn is not recorded. */
export const ACTIVITY_MAX_ENTRIES = 99;
export interface ActivityStart {
phase: CoachActivityPhase;
round: number;
labelKey: string;
label: string;
stepRef?: string;
count?: number;
stop?: CoachStopReason;
}
export interface ActivityPatch {
/** Model text; screened by the recorder, dropped when it fails. */
title?: string;
/** Model text; screened by the recorder, dropped when it fails. */
text?: string;
/** A new catalog label (the digest count once the round has settled). */
labelKey?: string;
label?: string;
count?: number;
}
export interface ActivityRecorder {
/** Opens an entry, sends its `running` frame and returns its id. */
start(entry: ActivityStart): string;
/** Updates an open entry and sends the frame again. */
update(id: string, patch: ActivityPatch): void;
/** Closes an entry with its final status and duration. */
finish(
id: string,
status: Exclude<CoachStepStatus, "running">,
patch?: ActivityPatch,
): void;
/** The entries so far, for `metricSource.activity`. */
meta(): CoachActivityMeta[];
/** The screened model text so far, or null when there is none. */
trail(): CoachTrail | null;
}
/** Sends nothing, stores nothing: a turn runs exactly as it did before. */
export function createNoopActivityRecorder(): ActivityRecorder {
return {
start: () => "a1",
update: () => undefined,
finish: () => undefined,
meta: () => [],
trail: () => null,
};
}
// ── Message keys ────────────────────────────────────────────────────────────
/** The labels with no count. */
export const COACH_ACTIVITY_KEYS = {
thinking: "insights.coach.activity.thinking",
fetching: "insights.coach.activity.fetching",
remember: "insights.coach.activity.remember",
plan: "insights.coach.activity.plan",
asking: "insights.coach.activity.asking",
answer: "insights.coach.activity.answer",
} as const;
/** The `stop` entry's label, by reason. */
export const COACH_ACTIVITY_STOP_KEYS: Readonly<
Record<CoachStopReason, string>
> = {
budget: "insights.coach.activity.stop.budget",
time: "insights.coach.activity.stop.time",
cap: "insights.coach.activity.stop.cap",
no_progress: "insights.coach.activity.stop.noProgress",
};
/** "Summarising N readings…", while a round's results are folded in. */
export function activityDigestKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.digest", count, locale);
}
/** "N readings from {areas}", once the round has settled. */
export function activityDigestDoneKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.digestDone", count, locale);
}
/** "N areas", the `{areas}` of the settled digest. */
export function activityAreasKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.areas", count, locale);
}
/** "Recalls N things you told it". */
export function activityMemoryKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.memory", count, locale);
}
/**
* The one-line summary once the turn is done: "Thought it through, N lookups,
* S s", or the seconds alone on a turn that looked nothing up.
*/
export function activitySummaryKey(lookups: number, locale: Locale): string {
return lookups === 0
? "insights.coach.activity.summaryNoLookups"
: pluralKey("insights.coach.activity.summary", lookups, locale);
}
+24
View File
@@ -231,6 +231,7 @@ export const COACH_FOLLOW_UP_KEYS: Readonly<Record<CoachFollowUpKind, string>> =
as_table: "coach.followUp.asTable",
related_metric: "coach.followUp.relatedMetric",
continue: "coach.followUp.continue",
change_assumption: "coach.followUp.changeAssumption",
};
export const COACH_FOLLOW_UP_UI_KEYS = {
@@ -253,6 +254,29 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
return `coach.clarify.window.${window}`;
}
// ── Replies, assumptions, interim tables (v1.41) ──────────────────────────
/** The group of reply pills under the latest answer. */
export const COACH_SUGGESTED_REPLIES_KEYS = {
groupLabel: "insights.coach.suggestedReplies.groupLabel",
} as const;
/** "Assumed: last 30 days. Change". */
export const COACH_ASSUMPTION_KEYS = {
line: "insights.coach.assumption.line",
change: "insights.coach.assumption.change",
} as const;
/** The one-line preview of a table read while the turn still runs. */
export function interimLabelKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.interim.label", count, locale);
}
/** The legend of a chart comparing two periods ("{a} vs {b}"). */
export const COACH_CHART_COMPARE_KEYS = {
periods: "insights.coach.chart.compare.periods",
} as const;
// ── Reuse ─────────────────────────────────────────────────────────────────
/** The prose of a turn answered from a stored table without a model call. */
+3
View File
@@ -129,6 +129,9 @@ export function followUpContextHint(
case "continue":
// Answered with its own context lines (`continue.ts`).
return null;
case "change_assumption":
// v1.41 — the contract is in place; no chip of this kind is offered yet.
return null;
}
}
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { COACH_MEMORY_CATEGORIES } from "@/lib/ai/coach/types";
import {
COACH_FACT_SOURCES,
HEALTH_MEMORY_CATEGORIES,
MEMORY_BLOCK_MAX_CHARS,
buildMemoryContextBlock,
buildPlanProgressLines,
decideFactProposal,
decidePlanProposal,
proposePlanFromTool,
rememberFactFromTool,
} from "../contract";
const IDS = { userId: "u1", conversationId: "c1" };
describe("memory contract stubs", () => {
it("build no block, write nothing and decide nothing", async () => {
await expect(
buildMemoryContextBlock({ ...IDS, locale: "en" }),
).resolves.toBeNull();
await expect(
rememberFactFromTool({
...IDS,
userMessage: "I want to reach 75 kg by December",
call: { category: "goal", fact: "Wants 75 kg by December", why: "w" },
}),
).resolves.toEqual({ kind: "declined", reason: "unavailable" });
await expect(
decideFactProposal({
...IDS,
messageId: "m1",
proposalId: "p1",
accept: true,
}),
).resolves.toEqual({ kind: "stale" });
await expect(
proposePlanFromTool({
...IDS,
call: {
metric: "WEIGHT",
ifCue: "after dinner",
thenAction: "walk",
reviewInDays: 14,
},
}),
).resolves.toEqual({ kind: "declined", reason: "unavailable" });
await expect(
decidePlanProposal({
...IDS,
messageId: "m1",
planId: "p1",
accept: true,
}),
).resolves.toEqual({ kind: "stale" });
await expect(buildPlanProgressLines("u1")).resolves.toEqual([]);
});
});
describe("memory contract constants", () => {
it("health categories are a subset of the memory categories", () => {
for (const category of HEALTH_MEMORY_CATEGORIES) {
expect(COACH_MEMORY_CATEGORIES).toContain(category);
}
expect([...HEALTH_MEMORY_CATEGORIES].sort()).toEqual([
"condition",
"constraint",
"medication",
]);
});
it("the column default is one of the sources", () => {
const schema = readFileSync(
join(__dirname, "../../../../../../prisma/schema.prisma"),
"utf8",
);
const model = schema.slice(schema.indexOf("model CoachFact {"));
const match = /source\s+String\s+@default\("(\w+)"\)/.exec(model);
expect(match?.[1]).toBeDefined();
expect(COACH_FACT_SOURCES).toContain(match?.[1]);
});
it("keeps the memory block small next to the inventory", () => {
expect(MEMORY_BLOCK_MAX_CHARS).toBeLessThanOrEqual(1_500);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* v1.41 — the contract of the Coach's memory inside a turn.
*
* The loop and the chat route reach facts and plans only through these
* functions: the memory block that goes into every turn, the `remember_fact`
* and `propose_plan` tools, the two decisions a person answers with a tap
* (`memoryDecision`, `planDecision` on the chat request), and the progress
* lines the daily briefing quotes.
*
* Until the implementations land, every function is a stub that answers "not
* available" and touches nothing: no block is built, no fact is written, no
* plan changes state. A turn behaves exactly as it did before.
*
* Rules every implementation keeps, so a caller can rely on them:
* - health facts (`condition`, `constraint`, `medication`) are only ever
* proposed, never saved without the person's tap;
* - a fact comes from the person's current message, never from a document or
* a quote;
* - the block is built only after the turn has passed its egress check, and
* never for a read-only sharing viewer;
* - fact and plan text never reaches a log or `annotate()`; counts and ids do.
*/
import type {
CoachMemoryCategory,
CoachMemoryNote,
CoachPlanProposal,
} from "@/lib/ai/coach/types";
/** Who put a fact into `coach_facts.source`. */
export const COACH_FACT_SOURCES = [
"user",
"coach",
"extracted",
"pattern",
] as const;
/** The categories that are proposed and never saved without a tap. */
export const HEALTH_MEMORY_CATEGORIES: ReadonlySet<CoachMemoryCategory> =
new Set(["condition", "constraint", "medication"]);
/** A remembered fact, as `remember_fact` may write it. */
export const REMEMBER_FACT_MAX_CHARS = 160;
/** The memory block in a turn's context, never trimmed before the inventory. */
export const MEMORY_BLOCK_MAX_CHARS = 1_500;
/** A proposed plan's review window, in days. */
export const PLAN_REVIEW_DAYS = { min: 7, max: 56 } as const;
// ── The memory block ───────────────────────────────────────────────────────
export interface MemoryContextBlock {
/** The fenced `WHAT YOU KNOW ABOUT THIS PERSON` section; data, not orders. */
text: string;
/** The facts in the block, for `last_used_at` and the `memory` entry. */
factIds: string[];
planIds: string[];
}
/**
* The facts and active plans a turn starts with, or null when there are none
* or memory is unavailable. The caller has already passed the egress check.
*/
export async function buildMemoryContextBlock(_args: {
userId: string;
conversationId: string | undefined;
locale: string;
}): Promise<MemoryContextBlock | null> {
return null;
}
// ── remember_fact ──────────────────────────────────────────────────────────
export interface RememberFactCall {
category: CoachMemoryCategory;
/** At most {@link REMEMBER_FACT_MAX_CHARS}. */
fact: string;
why: string;
}
export type RememberFactOutcome =
| { kind: "saved"; note: CoachMemoryNote }
| { kind: "proposed"; note: CoachMemoryNote }
| {
kind: "declined";
reason:
| "duplicate"
| "not_from_message"
| "one_per_answer"
| "invalid"
| "unavailable";
};
/**
* Runs a `remember_fact` call. `userMessage` is the person's current message,
* the only text a fact may come from.
*/
export async function rememberFactFromTool(_args: {
userId: string;
conversationId: string;
userMessage: string;
call: RememberFactCall;
}): Promise<RememberFactOutcome> {
return { kind: "declined", reason: "unavailable" };
}
/**
* Answers a fact proposal. The fact is read from the trail stored on
* `messageId`, never from the request.
*/
export async function decideFactProposal(_args: {
userId: string;
conversationId: string;
messageId: string;
proposalId: string;
accept: boolean;
}): Promise<
{ kind: "saved"; factId: string } | { kind: "declined" } | { kind: "stale" }
> {
return { kind: "stale" };
}
// ── propose_plan ───────────────────────────────────────────────────────────
export interface ProposePlanCall {
metric: string;
target?: string;
ifCue: string;
thenAction: string;
/** {@link PLAN_REVIEW_DAYS}. */
reviewInDays: number;
}
export type ProposePlanOutcome =
| { kind: "proposed"; proposal: CoachPlanProposal }
| {
kind: "declined";
reason: "one_per_answer" | "too_many_open" | "invalid" | "unavailable";
};
/** Runs a `propose_plan` call: writes the plan as `proposed`. */
export async function proposePlanFromTool(_args: {
userId: string;
conversationId: string;
call: ProposePlanCall;
}): Promise<ProposePlanOutcome> {
return { kind: "declined", reason: "unavailable" };
}
/**
* Answers a plan proposal: `active` on accept, `abandoned` on decline. Only a
* plan the assistant message proposed, and only the person's own.
*/
export async function decidePlanProposal(_args: {
userId: string;
conversationId: string;
messageId: string;
planId: string;
accept: boolean;
}): Promise<
| { kind: "activated"; reviewInDays: number }
| { kind: "abandoned" }
| { kind: "stale" }
> {
return { kind: "stale" };
}
// ── The briefing ───────────────────────────────────────────────────────────
/**
* One server-computed progress sentence per active plan, at most two, for
* the daily briefing prompt. The model may quote one; it may not invent one.
*/
export async function buildPlanProgressLines(
_userId: string,
): Promise<string[]> {
return [];
}
// ── Message keys ────────────────────────────────────────────────────────────
/** The memory note under an answer, and its two replies. */
export const COACH_MEMORY_KEYS = {
saved: "insights.coach.memory.saved",
undo: "insights.coach.memory.undo",
accept: "insights.coach.memory.accept",
decline: "insights.coach.memory.decline",
confirmed: "insights.coach.memory.confirmed",
} as const;
/** A plan proposal's two replies, and the line a decision turn answers with. */
export const COACH_PLAN_KEYS = {
accept: "insights.coach.plan.accept",
decline: "insights.coach.plan.decline",
confirmed: "insights.coach.plan.confirmed",
} as const;
/** The memory list in settings, and the link to it from the Coach. */
export const COACH_MEMORY_LIST_KEYS = {
link: "insights.coach.memoryLink",
edit: "settings.coach.memory.edit",
sourceUser: "settings.coach.memory.source.user",
sourceCoach: "settings.coach.memory.source.coach",
categoryMedication: "settings.ai.coachMemory.categoryMedication",
} as const;
+307 -13
View File
@@ -14,12 +14,21 @@
import { z } from "zod/v4";
import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types";
import {
ACTIVITY_TEXT_MAX_CHARS,
ACTIVITY_TITLE_MAX_CHARS,
} from "@/lib/ai/coach/activity/contract";
import {
PLAN_REVIEW_DAYS,
REMEMBER_FACT_MAX_CHARS,
} from "@/lib/ai/coach/memory/contract";
import {
SUGGESTED_ACTION_TYPES,
type CheckupIntervalId,
} from "@/lib/ai/coach/suggest-action";
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
import {
COACH_MEMORY_CATEGORIES,
coachKeyValueSchema,
coachScopeSourceSchema,
coachScopeWindowSchema,
@@ -55,8 +64,8 @@ const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]);
// them are written back into the model's context (`m<k>.r<n>` lines, the
// "keep looking" fetched list). Each is held to its exact server-minted
// shape, so a stored row edited by hand cannot carry text into a prompt.
/** A table of a message: `r1`..`r6`. */
const resultRefSchema = z.string().regex(/^r[1-6]$/);
/** A table of a message: `r1`..`r8`. */
const resultRefSchema = z.string().regex(/^r[1-8]$/);
/** A server-minted row id (cuid). */
const messageIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
@@ -67,7 +76,7 @@ export const coachStepSchema = z
id: z
.string()
.regex(/^s[1-9]\d{0,2}$/)
.describe("`s1`..`s12`, unique within a turn."),
.describe("`s1`..`s48`, unique within a turn."),
tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]),
labelKey: z
.string()
@@ -126,6 +135,18 @@ const coachChartSpecSchema = z.discriminatedUnion("kind", [
x: z.string(),
series: z.array(z.string()),
}),
z
.object({
kind: z.literal("compare"),
mode: z.enum(["periods", "metrics"]),
x: z.string(),
a: z.string(),
b: z.string(),
axes: z.union([z.literal(1), z.literal(2)]),
})
.describe(
"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.",
),
z.object({
kind: z.literal("bar"),
x: z.string(),
@@ -151,13 +172,13 @@ const coachResultSourceSchema = z.object({
});
const coachResultMetaShape = {
ref: resultRefSchema.describe("`r1`..`r6`, unique within a message."),
ref: resultRefSchema.describe("`r1`..`r8`, unique within a message."),
source: coachResultSourceSchema,
shape: z.enum(["timeSeries", "categoryCounts", "distribution", "single"]),
titleKey: z.string(),
title: z.string(),
rowCount: z.number().int().describe("The full row count, before any trim."),
chartKind: z.enum(["line", "bar", "histogram"]).nullable(),
chartKind: z.enum(["line", "compare", "bar", "histogram"]).nullable(),
displayed: z
.boolean()
.describe(
@@ -245,6 +266,50 @@ export const coachMethodSchema = z
"How an answer was reached: which sources, windows, counts and aggregation. Never a health value.",
});
// ── Choices and assumptions ───────────────────────────────────────────────
/** An id the server minted for a plan, a fact or a record event. */
const choiceIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
const coachChoiceValueSchema = z
.object({
metric: coachScopeSourceSchema.optional(),
window: coachWindowEnum.optional(),
comparison: z
.enum(["previous_period", "year_ago", "baseline_90d"])
.optional()
.describe("v1.41 — what a comparison is drawn against."),
goal: choiceIdSchema
.optional()
.describe("v1.41 — an active plan or a goal fact, by id."),
anchor: choiceIdSchema
.optional()
.describe(
"v1.41 — a medication start, an illness episode or a cycle phase, by id.",
),
})
.describe("What a choice stands for: catalog tokens and ids, never text.");
const coachAssumptionKindSchema = z.enum(["metric", "window", "comparison"]);
const coachAssumptionOptionSchema = z.object({
labelKey: z.string(),
label: z.string(),
value: coachChoiceValueSchema,
});
export const coachAssumptionSchema = z
.object({
kind: coachAssumptionKindSchema,
value: coachAssumptionOptionSchema,
alternatives: z.array(coachAssumptionOptionSchema).describe("At most 3."),
})
.meta({
id: "CoachAssumption",
description:
"v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a `change_assumption` follow-up.",
});
// ── Follow-ups ────────────────────────────────────────────────────────────
export const coachFollowUpSchema = z
@@ -261,6 +326,7 @@ export const coachFollowUpSchema = z
"as_table",
"related_metric",
"continue",
"change_assumption",
]),
labelKey: z.string(),
label: z
@@ -269,7 +335,7 @@ export const coachFollowUpSchema = z
anchor: z
.object({
// Empty for a related metric read without a table of its own.
ref: z.string().regex(/^(?:r[1-6])?$/),
ref: z.string().regex(/^(?:r[1-8])?$/),
domain: coachStepDomainSchema,
window: coachWindowEnum.optional(),
granularity: coachResultGranularitySchema.optional(),
@@ -282,6 +348,15 @@ export const coachFollowUpSchema = z
"True when the chip is answered from a stored table without a model call.",
),
origin: z.enum(["server", "model"]),
assumption: z
.object({
kind: coachAssumptionKindSchema,
value: coachChoiceValueSchema,
})
.optional()
.describe(
"v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks.",
),
})
.meta({
id: "CoachFollowUp",
@@ -293,21 +368,27 @@ export const coachFollowUpSchema = z
export const coachClarificationSchema = z
.object({
kind: z.enum(["metric", "window", "context"]),
kind: z
.enum(["metric", "window", "comparison", "goal", "anchor", "context"])
.describe("`comparison`, `goal` and `anchor` since v1.41."),
choices: z
.array(
z.object({
id: z.string().regex(/^c[1-4]$/),
labelKey: z.string(),
label: z.string(),
value: z.object({
metric: coachScopeSourceSchema.optional(),
window: coachWindowEnum.optional(),
}),
value: coachChoiceValueSchema,
}),
)
.describe("At most 4. Metric choices are always ones the record holds."),
freeText: z.boolean().describe("Whether a typed answer is welcome."),
assumption: z
.string()
.regex(/^c[1-4]$/)
.optional()
.describe(
"v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first choice.",
),
})
.meta({
id: "CoachClarification",
@@ -315,6 +396,178 @@ export const coachClarificationSchema = z
"The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.",
});
// ── Activity, memory and plans (v1.41) ────────────────────────────────────
const coachStopReasonSchema = z.enum(["budget", "time", "cap", "no_progress"]);
export const coachStopSchema = z
.object({
reason: coachStopReasonSchema,
rounds: z
.number()
.int()
.describe("The rounds the turn ran, the final one included."),
})
.meta({
id: "CoachStop",
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.",
});
const coachActivityMetaShape = {
id: z
.string()
.regex(/^a[1-9]\d?$/)
.describe("`a1`..`a99`, unique within a turn."),
phase: z.enum([
"thinking",
"memory",
"fetch",
"digest",
"checkpoint",
"remember",
"plan",
"asking",
"stop",
"answer",
]),
status: z.enum(["running", "done", "empty", "failed"]),
round: z.number().int().describe("The tool round, from 1."),
labelKey: z
.string()
.describe("Closed catalog key (`insights.coach.activity.*`)."),
label: z
.string()
.describe(
"Rendered on the server in the request locale from the catalog; never model text.",
),
stepRef: z
.string()
.regex(/^s[1-9]\d{0,2}$/)
.optional()
.describe("The `step` a `fetch` entry stands for."),
count: z
.number()
.int()
.optional()
.describe("Readings, lookups or entries the server counted."),
durationMs: z.number().int().optional(),
stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."),
};
export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({
id: "CoachActivityMeta",
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`.",
});
export const coachActivitySchema = z
.object({
...coachActivityMetaShape,
title: z
.string()
.max(ACTIVITY_TITLE_MAX_CHARS)
.optional()
.describe("A screened reasoning title or checkpoint sentence."),
text: z
.string()
.max(ACTIVITY_TEXT_MAX_CHARS)
.optional()
.describe("A screened reasoning summary for the round."),
})
.meta({
id: "CoachActivity",
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.",
});
const coachFactCategorySchema = z.enum(COACH_MEMORY_CATEGORIES);
const coachMemoryNoteMetaShape = {
proposal: z
.boolean()
.describe(
"`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes `factId`.",
),
proposalId: choiceIdSchema.optional(),
factId: choiceIdSchema.optional(),
category: coachFactCategorySchema,
};
export const coachMemoryNoteMetaSchema = z
.object(coachMemoryNoteMetaShape)
.meta({
id: "CoachMemoryNoteMeta",
description:
"v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text.",
});
export const coachMemoryNoteSchema = z
.object({
...coachMemoryNoteMetaShape,
fact: z.string().max(REMEMBER_FACT_MAX_CHARS),
})
.meta({
id: "CoachMemoryNote",
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.",
});
const coachPlanProposalMetaShape = {
planId: choiceIdSchema,
metric: z.string().max(64),
reviewInDays: z
.number()
.int()
.min(PLAN_REVIEW_DAYS.min)
.max(PLAN_REVIEW_DAYS.max),
};
export const coachPlanProposalMetaSchema = z
.object(coachPlanProposalMetaShape)
.meta({
id: "CoachPlanProposalMeta",
description:
"v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text.",
});
export const coachPlanProposalSchema = z
.object({
...coachPlanProposalMetaShape,
ifCue: z.string(),
thenAction: z.string(),
target: z.string().optional(),
})
.meta({
id: "CoachPlanProposal",
description:
"v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, accept }`. Owner only.",
});
export const coachTrailSchema = z
.object({
entries: z.array(
z.object({
id: z.string().regex(/^a[1-9]\d?$/),
title: z.string().max(ACTIVITY_TITLE_MAX_CHARS).optional(),
text: z.string().max(ACTIVITY_TEXT_MAX_CHARS).optional(),
}),
),
recalled: z.array(z.string().max(REMEMBER_FACT_MAX_CHARS)).optional(),
proposal: z
.object({
proposalId: choiceIdSchema,
category: coachFactCategorySchema,
fact: z.string().max(REMEMBER_FACT_MAX_CHARS),
})
.optional(),
})
.meta({
id: "CoachTrail",
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.",
});
// ── The cards and the usage envelope ──────────────────────────────────────
const coachSuggestionSchema = z
@@ -457,6 +710,27 @@ export const coachProvenanceSchema = z
.describe(
"v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise.",
),
activity: z
.array(coachActivityMetaSchema)
.optional()
.describe("v1.41 — the turn's trail, metadata only."),
stop: coachStopSchema
.optional()
.describe("v1.41 — why the answer was forced, when it was."),
assumptions: z
.array(coachAssumptionSchema)
.optional()
.describe(
"v1.41 — what the answer assumed instead of asking, at most two.",
),
memoryNote: coachMemoryNoteMetaSchema
.optional()
.describe(
"v1.41 — the fact this reply saved or proposes, without its text.",
),
planProposal: coachPlanProposalMetaSchema
.optional()
.describe("v1.41 — the plan this reply proposes, without its text."),
})
.meta({
id: "CoachProvenance",
@@ -487,6 +761,13 @@ export const coachStreamEventSchema = z
conversationId: z.string(),
messageId: z.string(),
usage: coachUsageSchema.optional(),
stop: coachStopSchema.optional(),
withheldResults: z
.literal(true)
.optional()
.describe(
"v1.41 — the turn was blocked after interim tables went out; remove them.",
),
}),
z.object({
type: z.literal("error"),
@@ -495,7 +776,14 @@ export const coachStreamEventSchema = z
reason: z.enum(AI_UNAVAILABLE_REASONS).optional(),
}),
z.object({ type: z.literal("step"), step: coachStepSchema }),
z.object({ type: z.literal("result"), result: coachResultTableSchema }),
z.object({
type: z.literal("result"),
result: coachResultTableSchema,
interim: z
.literal(true)
.optional()
.describe("v1.41 — sent while the turn still runs."),
}),
z.object({
type: z.literal("followUps"),
followUps: z.array(coachFollowUpSchema),
@@ -504,9 +792,15 @@ export const coachStreamEventSchema = z
type: z.literal("clarification"),
clarification: coachClarificationSchema,
}),
z.object({ type: z.literal("activity"), activity: coachActivitySchema }),
z.object({ type: z.literal("memoryNote"), note: coachMemoryNoteSchema }),
z.object({
type: z.literal("planProposal"),
proposal: coachPlanProposalSchema,
}),
])
.meta({
id: "CoachStreamEvent",
description:
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\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.",
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\n\\n` and dispatched on `type`. 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.",
});
+274 -7
View File
@@ -183,6 +183,32 @@ export const coachChatRequestSchema = z.object({
choiceId: z.string().min(1).max(8).optional(),
})
.optional(),
/**
* v1.41 — the person answered a fact proposal ("Yes, remember it" / "No").
* `messageId` is the assistant message that offered it, `proposalId` the
* proposal on that message. The client never sends the fact: the server
* reads it from what it stored on that message, so a tap can only accept
* what the Coach actually proposed.
*/
memoryDecision: z
.object({
messageId: z.string().min(1).max(64),
proposalId: z.string().min(1).max(64),
accept: z.boolean(),
})
.optional(),
/**
* v1.41 — the person answered a plan proposal ("Take on this plan" /
* "Not now"). `planId` is the proposed plan the assistant message carried;
* the server holds it to that message and to the person's own plans.
*/
planDecision: z
.object({
messageId: z.string().min(1).max(64),
planId: z.string().min(1).max(64),
accept: z.boolean(),
})
.optional(),
});
/**
@@ -247,6 +273,18 @@ export interface CoachUsage {
* step* → token* → provenance → result* → suggestion? →
* suggestedAction? → clarification? → followUps? → done
*
* v1.41 — three more additive frames: `activity` (the live trail: one
* entry per phase of the turn, upserted by id), `memoryNote` (a fact the
* Coach saved or proposes to save) and `planProposal` (a plan it proposes).
* `result` may carry `interim: true` while the turn still runs, and `done`
* carries `stop` when the answer was forced and `withheldResults` when the
* interim tables must be taken down again. The `reasoning` frame stays
* accepted and is never sent. Frame order:
*
* (activity | step)* → token* → provenance → result* → suggestion? →
* suggestedAction? → memoryNote? → planProposal? → clarification? →
* followUps? → done
*
* No new frame carries a top-level key the older native client decodes
* (`token`, `conversationId`, `messageId`, `code`, `message`, `suggestion`,
* `metricSource`, `usage`), so an older client drops them whole.
@@ -268,6 +306,13 @@ export type CoachStreamEvent =
conversationId: string;
messageId: string;
usage?: CoachUsage;
/** v1.41 — why the answer was forced, when it was. */
stop?: CoachStop;
/**
* v1.41 — the turn was blocked after interim tables went out; the
* client removes them.
*/
withheldResults?: true;
}
| {
type: "error";
@@ -281,9 +326,155 @@ export type CoachStreamEvent =
reason?: AiUnavailableReason;
}
| { type: "step"; step: CoachStep }
| { type: "result"; result: CoachResultTable }
| {
type: "result";
result: CoachResultTable;
/**
* v1.41 — sent while the turn still runs, right after the call that
* read it. The answer decides later whether it is shown or sits under
* "Data used".
*/
interim?: true;
}
| { type: "followUps"; followUps: CoachFollowUp[] }
| { type: "clarification"; clarification: CoachClarification };
| { type: "clarification"; clarification: CoachClarification }
| { type: "activity"; activity: CoachActivity }
| { type: "memoryNote"; note: CoachMemoryNote }
| { type: "planProposal"; proposal: CoachPlanProposal };
// ── Activity (v1.41) ────────────────────────────────────────────────────
// The live trail of a turn: what the Coach is doing right now, one entry per
// phase. The metadata is plaintext and persisted on `metricSource.activity`;
// the model-written `title` and `text` reach only the owner and are stored
// encrypted in `coach_messages.trail_encrypted`.
export type CoachActivityPhase =
| "thinking"
| "memory"
| "fetch"
| "digest"
| "checkpoint"
| "remember"
| "plan"
| "asking"
| "stop"
| "answer";
/** Why the loop forced its final answer. */
export type CoachStopReason = "budget" | "time" | "cap" | "no_progress";
export interface CoachStop {
reason: CoachStopReason;
/** The rounds the turn ran, the final one included. */
rounds: number;
}
/** Plaintext; rides `metricSource.activity`. */
export interface CoachActivityMeta {
/** `a1`..`a99`, unique within a turn; frames upsert by id. */
id: string;
phase: CoachActivityPhase;
status: CoachStepStatus;
/** The tool round the entry belongs to, from 1. */
round: number;
/** Closed catalog key (`insights.coach.activity.*`). */
labelKey: string;
/**
* The label rendered on the server in the request locale. Always catalog
* text with server-chosen values, never model text.
*/
label: string;
/** The `step` a `fetch` entry stands for (`s1`..). */
stepRef?: string;
/** Readings, lookups or remembered entries the server counted. */
count?: number;
durationMs?: number;
/** Set on the `stop` entry. */
stop?: CoachStopReason;
}
/** One trail entry on the wire: the metadata plus the owner-only model text. */
export interface CoachActivity extends CoachActivityMeta {
/** A screened reasoning title or checkpoint sentence, at most 80 chars. */
title?: string;
/** A screened reasoning summary for the round, at most 400 chars. */
text?: string;
}
// ── Memory and plans (v1.41) ────────────────────────────────────────────
/**
* The categories a Coach fact is filed under. The extraction's list in
* `facts.ts` plus `medication`, which only the in-turn `remember_fact` tool
* and the remember button write. `condition`, `constraint` and `medication`
* are health facts: never saved without the person's tap.
*/
export const COACH_MEMORY_CATEGORIES = [
"preference",
"goal",
"context",
"condition",
"constraint",
"medication",
] as const;
export type CoachMemoryCategory = (typeof COACH_MEMORY_CATEGORIES)[number];
/** Plaintext; rides `metricSource.memoryNote`. No fact text. */
export interface CoachMemoryNoteMeta {
/**
* `true`: a proposal waiting for the person ("Yes, remember it" / "No"),
* answered with `memoryDecision`. `false`: already saved, undone by
* deleting `factId`.
*/
proposal: boolean;
/** The proposal on this message (`proposal: true`). */
proposalId?: string;
/** The saved fact (`proposal: false`). */
factId?: string;
category: CoachMemoryCategory;
}
/** On the wire, owner only: the metadata plus the fact itself. */
export interface CoachMemoryNote extends CoachMemoryNoteMeta {
/** The fact as it will be stored, at most 160 chars. */
fact: string;
}
/** Plaintext; rides `metricSource.planProposal`. No plan text. */
export interface CoachPlanProposalMeta {
/** The `CoachPlan` row, written as `proposed`. */
planId: string;
/** The metric the plan moves, e.g. `WEIGHT`. */
metric: string;
/** 7..56. */
reviewInDays: number;
}
/** On the wire, owner only: the metadata plus the plan's own words. */
export interface CoachPlanProposal extends CoachPlanProposalMeta {
ifCue: string;
thenAction: string;
target?: string;
}
/**
* v1.41 — the decrypted `coach_messages.trail_encrypted` of one message, as
* `GET …/messages/{messageId}/trail` serves it to the owner. Model text and
* fact text only; the structure lives in `metricSource`.
*/
export interface CoachTrail {
/** Title and text per activity entry, by `CoachActivityMeta.id`. */
entries: Array<{ id: string; title?: string; text?: string }>;
/** The facts recalled into the turn (the `memory` entry). */
recalled?: string[];
/** The fact proposal's text, which a `memoryDecision` reads back. */
proposal?: {
proposalId: string;
category: CoachMemoryCategory;
fact: string;
};
}
// ── Steps (v1.39.4) ─────────────────────────────────────────────────────
// What the Coach is reading while a turn runs. Labels come from a closed
@@ -307,7 +498,7 @@ export type CoachStepReason =
| "invalid_arguments";
export interface CoachStep {
/** `s1`..`s12`, unique within a turn; frames upsert by id. */
/** `s1`..`s48`, unique within a turn; frames upsert by id. */
id: string;
tool: CoachToolName | "show_result" | "snapshot";
/** Closed catalog key, e.g. `coach.step.read`. */
@@ -351,6 +542,19 @@ export type CoachResultCell = string | number | null;
export type CoachChartSpec =
| { kind: "line"; x: string; series: string[] }
| {
/**
* v1.41 — two series on one chart: two periods overlaid (`periods`,
* one axis) or two metrics side by side (`metrics`, one or two axes).
* `a` and `b` name the value columns, `x` the shared column.
*/
kind: "compare";
mode: "periods" | "metrics";
x: string;
a: string;
b: string;
axes: 1 | 2;
}
| {
kind: "bar";
x: string;
@@ -374,7 +578,7 @@ export interface CoachResultSource {
/** Plaintext; rides `metricSource.results`. */
export interface CoachResultMeta {
/** `r1`..`r6`, per message. */
/** `r1`..`r8`, per message. */
ref: string;
source: CoachResultSource;
shape: CoachResultShape;
@@ -445,7 +649,8 @@ export type CoachFollowUpKind =
| "as_chart"
| "as_table"
| "related_metric"
| "continue";
| "continue"
| "change_assumption";
export interface CoachFollowUp {
/** `f1`..`f3`. */
@@ -463,25 +668,77 @@ export interface CoachFollowUp {
/** True when the chip is answered from a stored table, without a model. */
reuse: boolean;
origin: "server" | "model";
/**
* v1.41 — on a `change_assumption` chip: the assumption it replaces and
* the alternative it picks.
*/
assumption?: { kind: CoachAssumptionKind; value: CoachChoiceValue };
}
// ── Clarification (v1.39.4) ─────────────────────────────────────────────
// A question with choices. The question text is the assistant message
// itself, encrypted like any reply; only the choices ride here.
/** v1.41 — what a comparison is drawn against. */
export type CoachComparisonBasis =
"previous_period" | "year_ago" | "baseline_90d";
/**
* What a choice stands for. One field per kind; `goal` and `anchor` are ids
* of the person's own plans, facts or record events, never text.
*/
export interface CoachChoiceValue {
metric?: CoachScopeSource;
window?: CoachScopeWindow;
/** v1.41 */
comparison?: CoachComparisonBasis;
/** v1.41 — an active plan or a goal fact. */
goal?: string;
/** v1.41 — a medication start, an illness episode, a cycle phase. */
anchor?: string;
}
export interface CoachClarificationChoice {
id: string;
labelKey: string;
label: string;
value: { metric?: CoachScopeSource; window?: CoachScopeWindow };
value: CoachChoiceValue;
}
export type CoachClarificationKind =
"metric" | "window" | "comparison" | "goal" | "anchor" | "context";
export interface CoachClarification {
kind: "metric" | "window" | "context";
/** `comparison`, `goal` and `anchor` since v1.41. */
kind: CoachClarificationKind;
/** At most 4; metric choices are always ones the record holds. */
choices: CoachClarificationChoice[];
/** Whether a typed answer is welcome beside the choices. */
freeText: boolean;
/**
* v1.41 — the choice that applies when the person does not answer (`c1`..);
* the question names it, and it is the first choice.
*/
assumption?: string;
}
// ── Assumptions (v1.41) ─────────────────────────────────────────────────
// What an answer assumed instead of asking. Catalog values only, at most two
// per answer; each offers its alternatives as `change_assumption` chips.
export type CoachAssumptionKind = "metric" | "window" | "comparison";
export interface CoachAssumptionOption {
labelKey: string;
label: string;
value: CoachChoiceValue;
}
export interface CoachAssumption {
kind: CoachAssumptionKind;
value: CoachAssumptionOption;
/** At most 3. */
alternatives: CoachAssumptionOption[];
}
/**
@@ -672,6 +929,16 @@ export interface CoachProvenance {
* further "keep looking" chip, so an answer is continued at most once.
*/
continuationOf?: string;
/** v1.41 — the turn's trail, metadata only; the text is behind `…/trail`. */
activity?: CoachActivityMeta[];
/** v1.41 — why the answer was forced, when it was. */
stop?: CoachStop;
/** v1.41 — what the answer assumed instead of asking, at most two. */
assumptions?: CoachAssumption[];
/** v1.41 — the fact this reply saved or proposes, without its text. */
memoryNote?: CoachMemoryNoteMeta;
/** v1.41 — the plan this reply proposes, without its text. */
planProposal?: CoachPlanProposalMeta;
}
/**
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
BACKGROUND_REASONING_JOBS,
BACKGROUND_REASONING_LEVEL,
DEFAULT_REASONING_LEVEL,
DEFAULT_REASONING_MAX_EFFORT,
REASONING_LEVELS,
REASONING_MAX_EFFORTS,
REASONING_THINKING_BUDGET,
capReasoningLevel,
isReasoningLevel,
} from "../levels";
describe("reasoning levels", () => {
it("defaults to medium for a person and to no cap for an instance", () => {
expect(DEFAULT_REASONING_LEVEL).toBe("medium");
expect(DEFAULT_REASONING_MAX_EFFORT).toBe("high");
expect(REASONING_MAX_EFFORTS).toEqual(
REASONING_LEVELS.filter((level) => level !== "off"),
);
});
it("recognises exactly the four levels", () => {
for (const level of REASONING_LEVELS) {
expect(isReasoningLevel(level)).toBe(true);
}
for (const other of ["none", "minimal", "xhigh", "", null, 2]) {
expect(isReasoningLevel(other)).toBe(false);
}
});
it("caps a level at the operator's maximum and never raises one", () => {
expect(capReasoningLevel("high", "medium")).toBe("medium");
expect(capReasoningLevel("high", "low")).toBe("low");
expect(capReasoningLevel("medium", "high")).toBe("medium");
expect(capReasoningLevel("low", "medium")).toBe("low");
expect(capReasoningLevel("off", "low")).toBe("off");
});
it("gives every level above off a growing thinking budget of at least 1024", () => {
const budgets = REASONING_LEVELS.filter((level) => level !== "off").map(
(level) => REASONING_THINKING_BUDGET[level as "low"],
);
expect(budgets[0]).toBeGreaterThanOrEqual(1_024);
expect([...budgets].sort((a, b) => a - b)).toEqual(budgets);
});
it("has a level for every background job, operator-paid never above its own", () => {
expect(Object.keys(BACKGROUND_REASONING_LEVEL).sort()).toEqual(
[...BACKGROUND_REASONING_JOBS].sort(),
);
for (const job of BACKGROUND_REASONING_JOBS) {
const { level, operatorLevel } = BACKGROUND_REASONING_LEVEL[job];
expect(level).not.toBe("off");
expect(REASONING_LEVELS.indexOf(operatorLevel)).toBeLessThanOrEqual(
REASONING_LEVELS.indexOf(level),
);
}
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* v1.41 — the reasoning levels, the thinking budget behind each, and the
* background jobs that may reason.
*
* One vocabulary for every layer: the person's Coach preference
* (`coachPrefsJson.reasoning`), the operator's cap
* (`app_settings.ai_reasoning_max_effort`), the resolved value a call carries
* (`CompletionParams.reasoning.effort`) and the level `/api/auth/me` publishes.
* Each provider client maps it onto its own wire; nothing outside a client
* speaks a provider's dialect.
*
* A leaf on purpose, like `../reasoning-effort.ts`: `types.ts` imports it, and
* `types.ts` is reachable from workers that must not reach the provider
* machinery. No import from `provider-chain.ts`, and none from a client.
*
* Client-safe: no server import.
*/
/**
* `off` is the person's choice, not a promise about the wire: where a provider
* cannot switch reasoning off, the client sends its lowest setting instead and
* the settings say "Minimal" rather than "Off" (`offIsReal` in
* `support.ts`).
*/
export const REASONING_LEVELS = ["off", "low", "medium", "high"] as const;
export type ReasoningLevel = (typeof REASONING_LEVELS)[number];
/** The level an account that never chose one gets. */
export const DEFAULT_REASONING_LEVEL: ReasoningLevel = "medium";
/** The values the operator's cap may take; switching off is the other field. */
export const REASONING_MAX_EFFORTS = ["low", "medium", "high"] as const;
export type ReasoningMaxEffort = (typeof REASONING_MAX_EFFORTS)[number];
/** The cap an instance that never set one has: no cap. */
export const DEFAULT_REASONING_MAX_EFFORT: ReasoningMaxEffort = "high";
export function isReasoningLevel(value: unknown): value is ReasoningLevel {
return (
typeof value === "string" &&
(REASONING_LEVELS as readonly string[]).includes(value)
);
}
/** `level`, lowered to `max` when it is above it. */
export function capReasoningLevel(
level: ReasoningLevel,
max: ReasoningMaxEffort,
): ReasoningLevel {
return REASONING_LEVELS.indexOf(level) > REASONING_LEVELS.indexOf(max)
? max
: level;
}
/**
* Thinking tokens per level, for the providers that take a token budget
* rather than a named effort (Anthropic's manual mode, gateways that accept
* `max_tokens`). A round's output allowance is its answer budget plus this.
*/
export const REASONING_THINKING_BUDGET: Readonly<
Record<Exclude<ReasoningLevel, "off">, number>
> = {
low: 1_024,
medium: 4_096,
high: 12_000,
};
/**
* The background jobs that may reason. Every other unattended surface (status
* cards, batch notes, archetypes, workout and reaction lines, nudges, document
* reading, OCR) never does, and the inventory test freezes this list.
*/
export const BACKGROUND_REASONING_JOBS = [
"daily_briefing",
"period_narrative_month",
"period_narrative_week",
"memory_summary",
"memory_facts",
"memory_plans",
] as const;
export type BackgroundReasoningJob = (typeof BACKGROUND_REASONING_JOBS)[number];
/**
* The level each job asks for: `level` on the person's own plan, key or local
* model, `operatorLevel` when the operator pays for the call. Still capped by
* the operator's settings like every other call.
*/
export const BACKGROUND_REASONING_LEVEL: Readonly<
Record<
BackgroundReasoningJob,
{ level: ReasoningLevel; operatorLevel: ReasoningLevel }
>
> = {
daily_briefing: { level: "medium", operatorLevel: "low" },
period_narrative_month: { level: "medium", operatorLevel: "low" },
period_narrative_week: { level: "low", operatorLevel: "low" },
memory_summary: { level: "low", operatorLevel: "low" },
memory_facts: { level: "low", operatorLevel: "low" },
memory_plans: { level: "medium", operatorLevel: "low" },
};
// ── Message keys ────────────────────────────────────────────────────────────
/**
* One option label per level. `minimal` stands in for `off` where the active
* provider cannot switch reasoning off.
*/
export const REASONING_LEVEL_LABEL_KEYS = {
off: "insights.coach.reasoning.off",
minimal: "insights.coach.reasoning.minimal",
low: "insights.coach.reasoning.low",
medium: "insights.coach.reasoning.medium",
high: "insights.coach.reasoning.high",
} as const;
/** The Coach settings field around the options. */
export const REASONING_SETTING_KEYS = {
label: "insights.coach.reasoning.label",
hint: "insights.coach.reasoning.hint",
capped: "insights.coach.reasoning.capped",
disabled: "insights.coach.reasoning.disabled",
} as const;
/** The operator's two controls in the admin assistant section. */
export const REASONING_ADMIN_KEYS = {
enabled: "admin.assistant.reasoning.enabled",
max: "admin.assistant.reasoning.max",
} as const;
+62
View File
@@ -1,4 +1,5 @@
import type { ReasoningEffort } from "./reasoning-effort";
import type { ReasoningLevel } from "./reasoning/levels";
import { z } from "zod/v4";
// ─── Insight Result Schema ─────────────────────────────────
@@ -252,6 +253,37 @@ export interface AiMessage {
* A no-op on every other provider (their prefix caching is automatic).
*/
cacheBreakpoint?: boolean;
/**
* v1.41 — opaque provider state carried from one tool round to the next on
* an assistant turn: the reasoning items a provider requires back verbatim
* (encrypted reasoning items, thinking blocks with their signatures,
* reasoning details). Only the client whose `providerType` and `model` match
* re-inserts it; every other client drops it. Lives in memory for one turn:
* never persisted, never logged, never passed to `annotate()`.
*/
providerState?: AiProviderState;
}
/**
* v1.41 — provider state threaded between the rounds of one turn. `items` is
* whatever the producing client needs back, unchanged; nothing outside that
* client reads it.
*/
export interface AiProviderState {
providerType: ProviderType;
model: string;
items: unknown[];
}
/**
* v1.41 — one live reasoning event a client reports while a call runs.
* `title` is the first bold line of a summary once it has closed, `text` a
* finished summary, `done` the end of the reasoning for this call. Model
* text: the receiver screens it before anything reaches a person.
*/
export interface AiReasoningEvent {
kind: "title" | "text" | "done";
text: string;
}
export interface CompletionParams {
@@ -334,6 +366,20 @@ export interface CompletionParams {
* `ai-call-timeout-guard.test.ts`.
*/
timeoutPolicy?: "surface-ceiling";
/**
* v1.41 — the reasoning this call asks for, already resolved against the
* person's level, the operator's cap and what the provider supports
* (`src/lib/ai/reasoning/resolve.ts`). `summaries` asks for reasoning
* summaries where the provider offers them. Absent → today's behaviour: the
* client sends exactly what it sent before the field existed.
*/
reasoning?: { effort: ReasoningLevel; summaries: boolean };
/**
* v1.41 — live reasoning events, called as they arrive on clients that read
* the provider's stream, or once after the reply on clients that do not.
* Never called when `reasoning` is absent.
*/
onReasoning?: (event: AiReasoningEvent) => void;
}
export interface CompletionResult {
@@ -358,6 +404,22 @@ export interface CompletionResult {
* `"tool_calls"`. Absent when the provider does not surface it.
*/
finishReason?: "stop" | "tool_calls" | "length";
/**
* v1.41 — what the call reasoned, when it reasoned. `summary` holds the
* finished summaries (model text, unscreened), `tokens` the reasoning tokens
* the provider billed where it reports them, `downgradedTo` the level the
* client fell back to after the provider refused the requested one.
*/
reasoning?: {
summary: string[];
tokens: number | null;
downgradedTo?: ReasoningLevel;
};
/**
* v1.41 — the state the next round of the same turn hands back on its
* assistant message (see {@link AiMessage.providerState}).
*/
providerState?: AiProviderState;
}
/**
+2
View File
@@ -232,6 +232,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
// v1.39.4 — the tables of values a turn read, JSON under the same codec.
{ model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" },
// v1.41 — the model text of the turn's trail, JSON under the same codec.
{ model: "CoachMessage", field: "trailEncrypted", kind: "bytes" },
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
// v1.39.3 — the title: the opening words of the first message, or a rename.
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
+69
View File
@@ -90,6 +90,16 @@ export interface CoachMessageBackupEntry {
* unreadable-row marker stands in for tables that did not decrypt.
*/
resultsJson?: string | null;
/**
* v1.41 — the model text of the turn's trail, as ciphertext in base64.
* Present on a disaster-recovery payload only; null on a turn without one.
*/
trailEncrypted?: string | null;
/**
* v1.41 — the same trail as readable JSON (a `CoachTrail`), on a portable
* payload only; null on a turn without one.
*/
trailJson?: string | null;
createdAt: string;
}
@@ -167,6 +177,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
tokensUsed: true,
model: true,
resultsEncrypted: true,
trailEncrypted: true,
createdAt: true,
},
},
@@ -268,6 +279,14 @@ export async function buildCoachBackupSection(
: null,
}
: { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
// v1.41 — and so does the trail.
...(disasterRecovery
? {
trailEncrypted: message.trailEncrypted
? Buffer.from(message.trailEncrypted).toString("base64")
: null,
}
: { trailJson: decryptTurnSoft(message.trailEncrypted) }),
createdAt: message.createdAt.toISOString(),
})),
attachments: row.attachments.map((attachment) => ({
@@ -340,6 +359,8 @@ export type RestoredCoachMessage = Pick<
| "model"
| "resultsEncrypted"
| "resultsJson"
| "trailEncrypted"
| "trailJson"
>
>;
@@ -424,6 +445,7 @@ export async function restoreCoachData(
tokensUsed: message.tokensUsed ?? null,
model: message.model ?? null,
resultsEncrypted: resolveResultsBytes(message),
trailEncrypted: resolveTrailBytes(message),
createdAt: new Date(message.createdAt),
})),
},
@@ -497,6 +519,18 @@ function resolveResultsBytes(
return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
}
/** v1.41 — a turn's stored trail, from either end of the contract. */
function resolveTrailBytes(
message: RestoredCoachMessage,
): Uint8Array<ArrayBuffer> | null {
if (message.trailEncrypted !== undefined) {
return message.trailEncrypted === null
? null
: decodeBase64(message.trailEncrypted);
}
return message.trailJson ? encryptToBytes(message.trailJson) : null;
}
function resolveTitleBytes(
conversation: RestoredCoachConversation,
): Uint8Array<ArrayBuffer> {
@@ -571,6 +605,15 @@ export interface CoachFactBackupEntry {
category: string;
confidence: number;
sourceConversationId: string | null;
/** v1.41 — `user`, `coach`, `extracted` or `pattern`. */
source: string;
/** v1.41 */
lastUsedAt: string | null;
/**
* v1.41 — the message the fact came out of. A bare id like
* `sourceConversationId`, resolved the same way on restore.
*/
sourceMessageId: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
@@ -670,6 +713,9 @@ export async function buildCoachMemoryBackupSection(
category: row.category,
confidence: row.confidence,
sourceConversationId: row.sourceConversationId,
source: row.source,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
sourceMessageId: row.sourceMessageId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
@@ -726,6 +772,9 @@ const COACH_FACT_BACKUP_SELECT = {
category: true,
confidence: true,
sourceConversationId: true,
source: true,
lastUsedAt: true,
sourceMessageId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
@@ -795,6 +844,9 @@ export type RestoredCoachFact = Pick<
| "fact"
| "confidence"
| "sourceConversationId"
| "source"
| "lastUsedAt"
| "sourceMessageId"
| "deletedAt"
>
>;
@@ -859,12 +911,17 @@ export interface CoachMemoryRestoreInput {
*
* Plans go in before reminders for the same reason one step down: a reminder's
* `relatedPlanId` is resolved against the plans this function has just written.
*
* A fact's `sourceMessageId` (v1.41) is resolved against the messages the
* conversation restore wrote, and nulled and reported the same way when it
* names one the file did not carry.
*/
export async function restoreCoachMemoryData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: CoachMemoryRestoreInput,
conversationIds: ReadonlySet<string>,
messageIds: ReadonlySet<string>,
skips: RestoreSkipLog,
): Promise<CoachMemoryRestoreCleared> {
const [clearedFacts, clearedPlans, clearedReminders] = await Promise.all([
@@ -884,6 +941,12 @@ export async function restoreCoachMemoryData(
danglingRefs.push(id);
return null;
};
const resolveMessage = (id: string | null | undefined) => {
if (!id) return null;
if (messageIds.has(id)) return id;
danglingRefs.push(id);
return null;
};
if (payload.coachFacts.length > 0) {
await tx.coachFact.createMany({
@@ -894,6 +957,12 @@ export async function restoreCoachMemoryData(
category: fact.category,
confidence: fact.confidence ?? 50,
sourceConversationId: resolveConversation(fact.sourceConversationId),
// A file written before v1.41 states no source; every fact then came
// from the extraction or the matcher, which is what the column's
// default says too.
source: fact.source ?? "extracted",
lastUsedAt: fact.lastUsedAt ? new Date(fact.lastUsedAt) : null,
sourceMessageId: resolveMessage(fact.sourceMessageId),
createdAt: new Date(fact.createdAt),
updatedAt: new Date(fact.updatedAt),
deletedAt: fact.deletedAt ? new Date(fact.deletedAt) : null,
+11
View File
@@ -35,6 +35,7 @@ import {
} from "@/lib/medications/course-window";
import { Buffer } from "node:buffer";
import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels";
import { prisma, toJson } from "@/lib/db";
import { normaliseSourceInstance } from "@/lib/validations/inbound-documents";
import { auditLog } from "@/lib/auth/audit";
@@ -865,6 +866,11 @@ export async function restoreBackup(
settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled:
settings.assistantDocumentAiEnabled ?? true,
// v1.41 — absent on a file written before the controls existed,
// which restores them at their defaults: allowed, no cap.
aiReasoningEnabled: settings.aiReasoningEnabled ?? true,
aiReasoningMaxEffort:
settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT,
moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
@@ -2475,6 +2481,11 @@ export async function restoreBackup(
ownerId,
payload,
new Set(payload.coachConversations.map((c) => c.id)),
new Set(
payload.coachConversations.flatMap((c) =>
c.messages.map((m) => m.id),
),
),
skips,
);
@@ -698,4 +698,21 @@ describe("a Coach turn's stored tables are bounded on restore", () => {
const sealed = "A".repeat(300 * 1024);
expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(false);
});
it("bounds the trail's text the same way, at its own 16 KB", () => {
const fits = JSON.stringify({
entries: [{ id: "a1", text: "x".repeat(15 * 1024) }],
});
expect(parseWithResults({ trailJson: fits }).success).toBe(true);
expect(
parseWithResults({ trailEncrypted: "A".repeat(30 * 1024) }).success,
).toBe(true);
const tooLong = JSON.stringify({
entries: [{ id: "a1", text: "x".repeat(17 * 1024) }],
});
expect(parseWithResults({ trailJson: tooLong }).success).toBe(false);
expect(
parseWithResults({ trailEncrypted: "A".repeat(40 * 1024) }).success,
).toBe(false);
});
});
@@ -4,6 +4,7 @@ import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachPrefsSchema,
coachReasoningLevel,
parseCoachPrefs,
} from "../coach-prefs";
@@ -220,3 +221,39 @@ describe("parseCoachPrefs", () => {
);
});
});
describe("reasoning (v1.41)", () => {
it("is absent on a legacy blob and reads as medium", () => {
const prefs = parseCoachPrefs({ tone: "neutral" });
expect(prefs).not.toHaveProperty("reasoning");
expect(coachReasoningLevel(prefs)).toBe("medium");
expect(coachReasoningLevel(DEFAULT_COACH_PREFS)).toBe("medium");
});
it.each(["off", "low", "medium", "high"] as const)(
"round-trips %s",
(level) => {
const prefs = parseCoachPrefs({ reasoning: level });
expect(prefs.reasoning).toBe(level);
expect(coachReasoningLevel(prefs)).toBe(level);
},
);
it("rejects an unknown level on write", () => {
expect(coachPrefsSchema.safeParse({ reasoning: "max" }).success).toBe(
false,
);
});
it("drops an unknown stored level alone and keeps every other field", () => {
const prefs = parseCoachPrefs({
reasoning: "xhigh",
tone: "concise",
excludeMetrics: ["weight"],
});
expect(prefs.reasoning).toBeUndefined();
expect(coachReasoningLevel(prefs)).toBe("medium");
expect(prefs.tone).toBe("concise");
expect(prefs.excludeMetrics).toEqual(["weight"]);
});
});
+28
View File
@@ -21,6 +21,8 @@
*/
import { z } from "zod/v4";
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs";
import { TRAIL_MAX_BYTES } from "@/lib/ai/coach/activity/contract";
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
import {
AllergyCategory,
AllergySeverity,
@@ -1056,6 +1058,9 @@ const appSettingsBackupSchema = z
// was retired still carries that key; `passthrough` keeps it harmless and
// the restore writes nothing for it.
assistantDocumentAiEnabled: z.boolean().optional(),
// v1.41 — optional for the same reason.
aiReasoningEnabled: z.boolean().optional(),
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
moduleAvailabilityJson: z.unknown().nullable(),
documentMaxFileBytes: z.number().int(),
documentQuotaBytes: z.string().regex(/^\d+$/),
@@ -1478,6 +1483,21 @@ const coachResultsSealedSchema = base64BytesSchema.refine(
{ message: "Coach result tables exceed the stored size" },
);
/** The trail's model text, bounded at restore like the tables above. */
const COACH_TRAIL_SEALED_MAX_CHARS = 2 * TRAIL_MAX_BYTES;
const coachTrailJsonSchema = z
.string()
.refine(
(value) => new TextEncoder().encode(value).byteLength <= TRAIL_MAX_BYTES,
{ message: `Coach trail exceeds ${TRAIL_MAX_BYTES} bytes` },
);
const coachTrailSealedSchema = base64BytesSchema.refine(
(value) => value.length <= COACH_TRAIL_SEALED_MAX_CHARS,
{ message: "Coach trail exceeds the stored size" },
);
/**
* One Coach turn.
*
@@ -1502,6 +1522,9 @@ const coachMessageBackupSchema = z
// readable JSON on a portable one, absent on every file written before.
resultsEncrypted: coachResultsSealedSchema.nullable().optional(),
resultsJson: coachResultsJsonSchema.nullable().optional(),
// v1.41 — the trail's model text, the same two ends as the tables.
trailEncrypted: coachTrailSealedSchema.nullable().optional(),
trailJson: coachTrailJsonSchema.nullable().optional(),
createdAt: isoDateTime,
})
.passthrough();
@@ -1549,6 +1572,11 @@ const coachFactBackupSchema = z
category: z.string().min(1),
confidence: z.number().int().min(0).max(100).optional(),
sourceConversationId: z.string().nullable().optional(),
// v1.41 — absent on every file written before; restored at the column
// defaults then.
source: z.string().min(1).max(32).optional(),
lastUsedAt: isoDateTime.nullable().optional(),
sourceMessageId: z.string().nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
+21
View File
@@ -14,6 +14,12 @@
*/
import { z } from "zod/v4";
import {
DEFAULT_REASONING_LEVEL,
REASONING_LEVELS,
type ReasoningLevel,
} from "@/lib/ai/reasoning/levels";
/**
* Tone presets the Coach system-prompt prefix toggles between. v1.4.22
* landed `warm` as the default; `neutral` strips the warmth language
@@ -196,6 +202,16 @@ export const coachPrefsSchema = z.object({
.describe(
"v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off.",
),
// v1.41 — how hard the Coach thinks before it answers. No schema default,
// like the two keys above, so a legacy blob and `parse({})` stay
// byte-identical; an absent key reads as `medium` (`coachReasoningLevel`).
// The operator's switch and cap apply on top, server-side.
reasoning: z
.enum(REASONING_LEVELS)
.optional()
.describe(
"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`.",
),
});
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
@@ -214,6 +230,11 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = {
defaultWindow: "allTime",
};
/** The person's reasoning level; an absent key is the default, `medium`. */
export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel {
return prefs.reasoning ?? DEFAULT_REASONING_LEVEL;
}
/**
* Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`,
* falling back to defaults when the row is null. Keeps the call sites at
@@ -165,6 +165,8 @@ describe("canonical disaster-recovery backup round-trip", () => {
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: false,
aiReasoningEnabled: false,
aiReasoningMaxEffort: "medium",
moduleAvailabilityJson: { insights: true, nutrients: false },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: BigInt("9876543210"),
+34 -1
View File
@@ -143,6 +143,10 @@ const PHQ9_RESPONSES = JSON.stringify({
const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU=";
const INVENTORY_NOTE = "second pack, kept in the kitchen drawer";
const COACH_FACT = "does not tolerate ACE inhibitors";
// v1.41 — the model text of a turn's trail, stored encrypted beside the turn.
const COACH_TRAIL_JSON = JSON.stringify({
entries: [{ id: "a1", title: "Checking the trend", text: "Read 30 days." }],
});
const COACH_PLAN_CUE = "if the evening reading is over 140";
const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow";
const COACH_REMINDER_NOTE = "ask how the evening walks are going";
@@ -1092,6 +1096,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
trailEncrypted: encryptToBytes(COACH_TRAIL_JSON),
providerType: "anthropic",
model: "claude-opus-5",
tokensUsed: 812,
@@ -1131,6 +1136,9 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
const healthThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
const healthAnswer = await prisma.coachMessage.findFirstOrThrow({
where: { conversationId: healthThread.id, role: "assistant" },
});
await prisma.coachFact.create({
data: {
userId: OWNER_ID,
@@ -1138,6 +1146,9 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
category: "constraint",
confidence: 80,
sourceConversationId: healthThread.id,
source: "user",
lastUsedAt: AT("2026-07-20T10:00:00.000Z"),
sourceMessageId: healthAnswer.id,
},
});
const plan = await prisma.coachPlan.create({
@@ -1768,6 +1779,9 @@ describe("every model the plan claims two-ended survives a real restore", () =>
results: message.resultsEncrypted
? decryptFromBytes(message.resultsEncrypted)
: null,
trail: message.trailEncrypted
? decryptFromBytes(message.trailEncrypted)
: null,
})),
attachments: thread.attachments.map((a) => a.documentId),
})),
@@ -1786,6 +1800,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
model: null,
tokensUsed: null,
results: null,
trail: null,
},
{
role: "assistant",
@@ -1793,6 +1808,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
model: "claude-opus-5",
tokensUsed: 812,
results: COACH_RESULTS_JSON,
trail: COACH_TRAIL_JSON,
},
],
attachments: [],
@@ -1810,6 +1826,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
model: null,
tokensUsed: null,
results: null,
trail: null,
},
],
attachments: [vaultDocument.id],
@@ -1830,17 +1847,26 @@ describe("every model the plan claims two-ended survives a real restore", () =>
const fact = await prisma.coachFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const restoredAnswer = await prisma.coachMessage.findFirstOrThrow({
where: { conversationId: restoredThread.id, role: "assistant" },
});
expect({
fact: decryptFromBytes(fact.factEncrypted),
category: fact.category,
confidence: fact.confidence,
source: fact.sourceConversationId,
origin: fact.source,
lastUsedAt: fact.lastUsedAt?.toISOString(),
sourceMessageId: fact.sourceMessageId,
deletedAt: fact.deletedAt,
}).toEqual({
fact: COACH_FACT,
category: "constraint",
confidence: 80,
source: restoredThread.id,
origin: "user",
lastUsedAt: "2026-07-20T10:00:00.000Z",
sourceMessageId: restoredAnswer.id,
deletedAt: null,
});
expect({
@@ -3077,6 +3103,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
trailEncrypted: encryptToBytes(COACH_TRAIL_JSON),
createdAt: AT("2026-07-20T09:59:00.000Z"),
},
],
@@ -3091,8 +3118,12 @@ describe("every model the plan claims two-ended survives a real restore", () =>
coachConversations: Array<{ messages: Array<Record<string, unknown>> }>;
};
const [turn] = coachConversations[0].messages;
expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
expect(turn).toMatchObject({
resultsJson: COACH_RESULTS_JSON,
trailJson: COACH_TRAIL_JSON,
});
expect(turn).not.toHaveProperty("resultsEncrypted");
expect(turn).not.toHaveProperty("trailEncrypted");
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
@@ -3120,6 +3151,8 @@ describe("every model the plan claims two-ended survives a real restore", () =>
expect(decryptFromBytes(restored.resultsEncrypted!)).toBe(
COACH_RESULTS_JSON,
);
expect(restored.trailEncrypted).not.toBeNull();
expect(decryptFromBytes(restored.trailEncrypted!)).toBe(COACH_TRAIL_JSON);
});
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {