Commit Graph
589 Commits
Author SHA1 Message Date
Marc-André Bombeck 996d2519d1 feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.

The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.

Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
2026-10-08 09:54:42 +02:00
Marc-André Bombeck 2622f63571 Merge database and ingest work into release/v1.42.0 2026-10-08 08:48:55 +02:00
Marc-André Bombeck 900a5c4f1f perf: stop keeping folded samples and re-reading duplicates
The nightly folds soft-deleted every raw Apple Health sample they folded
into an hourly or daily stats row and kept it for 75 days. Nothing ever
read those rows as deletions; they only held a re-upload of the sample
off, and they were most of the measurements table and its indexes.

Ingest now refuses such a re-upload directly: a raw sample of a dense or
mean type, old enough to have been folded, whose hour or day a live
stats row covers, is a duplicate with reason folded_window. The batch
route and the Apple Health export import apply the same rule. With that
in place the dense retention and the daily-mean consolidation delete the
raw rows outright, and a new compaction-tombstone-purge job removes the
backlog: 5,000 rows a batch, one account at a time under the restore
lock, a short pause between batches, resumable because the predicate is
idempotent, queued at every boot and continuing itself while it makes
progress. A person's own deletions are never in its predicate. The sync
feed leaves compaction tombstones out and a restore does not write them
back; all three classify through the same function.

The batch route answers an exact re-sent sample without a transaction:
one read over the batch's sample ids, live or deleted, decides what the
reconciler would have decided, at the same point in the loop, so every
status, reason, counter and the cross-source merge stay as they were. A
batch of duplicates only no longer queues PR detection, a reminder sweep
or the detection audit row. Caches are evicted only for a foreground or
manual sync or a batch that wrote a hand-entered reading, and marked
stale otherwise; the targets cache follows the same split.

Also:
- POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a
  REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of
  its own; it refuses while the purge runs, drops invalid leftover copies
  and stops at the first failed rebuild. Runbook in docs/ops.
- The pure volume queues keep their finished jobs for 96 hours instead
  of seven days, never less than the failure window plus a day.
- work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also
  when the statement timeout is disabled.
- The PR-detection fallback tick re-scans only accounts whose readings
  or workouts changed since the previous tick.
- Type discovery for the status refill walks the live-rows index as a
  loose index scan instead of grouping every live row.
- The host metrics chart polls every five minutes, like its sampler.
- The batch route records per-type HealthKit arrivals.
2026-10-08 08:41:25 +02:00
Marc-André Bombeck aca497d9ac fix(insights): correlate residuals, not the calendar
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.

Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.

The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.

scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
2026-10-08 08:32:15 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
2026-10-07 16:55:25 +02:00
Marc-André Bombeck e406b9c7ab fix(coach): calm the answer column, its replies and its focus marks
The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.

Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.

Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.

A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
2026-10-06 21:24:24 +02:00
Marc-André Bombeck 3b5fb1e900 Merge the Coach reasoning interface into release/v1.41.0 2026-10-06 20:48:42 +02:00
Marc-André Bombeck 11bafd68b5 feat(coach): one quiet status line, reply pills and memory notes in the thread
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.

Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
2026-10-06 20:47:17 +02:00
Marc-André Bombeck 362818418e test(coach): count the conversation writes that may stamp updatedAt
The guard allowed every unnamed coachConversation write in a listed file,
so a background write added beside the rename and the document attach
would have passed. Each listed file now carries how many such writes it
holds, and a changed count fails.
2026-10-06 20:29:56 +02:00
Marc-André Bombeck e59fef2545 fix(coach): catalog the decline and clarify-choice strings, and let a briefing asked for by hand reason 2026-10-06 20:14:11 +02:00
Marc-André Bombeck ae5cee331e feat(coach): resolve the turn's thinking depth from the person and the operator
The chat route hands the turn the level resolveCoachTurnReasoningLevel
returns: the stored Coach preference, absent read as medium, after the
operator's switch and cap. The turn still applies the operator-paid
ceiling once its chain is known. The controls loader no longer throws
when the request cache cannot serve it; it fails closed to off. The
admin guard pins the route to the resolver.
2026-10-06 19:57:04 +02:00
Marc-André Bombeck 656d8b8563 Merge remote-tracking branch 'origin/release/v1.41.0' into v141/d-settings 2026-10-06 19:44:56 +02:00
Marc-André Bombeck fadb2ff6ed feat(insights): let the daily briefing and the period narratives reason
runStatusCompletion and runBriefingCompletion take an optional
reasoningJob. Only a named job carries reasoning, resolved against the
operator's switch and cap and the payer; a call without one, which is
every status card, batch note, derived score and workout line, sends
exactly what it sent before. Thinking is reserved on the day's ledger
with the answer budget, and a reasoning status call falls back to a
90 s ceiling instead of 60 s.

The briefing reasons on its generation and phrasing re-roll, the weekly
and monthly narratives on their first pass; the JSON and grounding
repairs do not. An inventory test freezes which files may name a job.
2026-10-06 19:44:52 +02:00
Marc-André Bombeck 74e68745c1 feat(ai): resolve the reasoning level against the operator's controls
Add resolveReasoning, the one decision for how hard a call may think:
the operator's switch, then the provider's ability, then the requested
level (the person's Coach preference, or a background job's own level),
then the operator's cap, then a medium ceiling when the operator pays.
The answer names the layer that decided it.

loadReasoningControls reads the two app_settings columns, memoised per
request, read afresh in background runs, and fails closed to "off".
loadCoachReasoningState resolves the published Coach state from the
memoised provider presence; resolveJobReasoning resolves one background
call and never fails it.

A guard proves the switch and the cap over the whole input space and
requires every call that carries reasoning to take it from the
resolver.
2026-10-06 19:44:51 +02:00
Marc-André Bombeck 0ba698c54f Merge memory, plans and voice into release/v1.41.0 2026-10-06 19:33:17 +02:00
Marc-André Bombeck 912558b96a feat(coach): answer first, short and without filler
Give the Coach one statement of how an answer is shaped, in the German and
English bodies and on every other locale: the first sentence answers, one
carrying number with its unit and window, the person's own baseline, an
assumption in one line, at most one next step and no habitual closing
question, 40 to 120 words, and a closed list of filler it never writes.

- Brief is 30 to 70 words in one paragraph, detailed up to 220 in three.
- Motivational-interviewing moves only when the person opens a change topic,
  at most one, never as a closing question; the confidence ruler only on an
  explicit plan request.
- The few-shot examples are rewritten to model the voice.
- Result tables: show the numbers whenever they carry the answer and span
  more than one value, a table for exact values or up to twelve like rows,
  one figure alone only for a moment.
- Follow-up chips are the answer's only "what next".
- A voice grader for the eval (length, filler, emoji, next steps, closing
  question, warm-up opening) plugs into the golden set as criteria; five
  voice cases in German and English.
- A guard holds both prompt bodies to the rules and every example answer to
  the grader, each check broken once on purpose.
2026-10-06 19:32:32 +02:00
Marc-André Bombeck acec87f02c feat(coach): give every turn its memory and let the Coach remember and propose plans
What a person told the Coach never reached a tool-mode turn: facts and plans
rode the snapshot's memory block, which tool mode does not send, and the
extraction ran only past twenty turns and failed to parse on every
non-Codex chain, because the status chain runs in JSON-object mode while the
prompt asked for a bare array.

- Memory block: WHAT YOU KNOW ABOUT THIS PERSON, built per turn behind the
  wire egress check (nothing is read on a refusal), fenced as data, at most
  1,500 characters, never trimmed before the inventory. Confirmed facts
  first, then the newest; active plans with a server-computed progress line;
  reminders the person asked for. Medication facts stay out while the
  medications module is off. Building it stamps last_used_at without moving
  updated_at. buildCoachToolRequest and buildCoachProviderPrompts take it as
  an optional memoryBlock; the snapshot memory keeps only the narrative and
  trend recall.
- remember_fact: preferences, goals and context are saved at once
  (source coach, source_message_id set); anything that names a condition,
  limitation or medication, by category or by wording, is only proposed.
  A fact must come from the person's current message; one note per answer.
- The person's answer reads the proposed fact from the encrypted trail on
  the assistant message, never from the request.
- propose_plan writes a proposed plan; the tap activates it with its review
  window counted from the tap. One per answer, three open at most.
- Progress: one sentence per active plan from the person's own day series
  and units, also offered to the briefing (two lines at most, behind the
  briefing capability). The plan review and the progress line read a step
  plan as daily totals instead of per-sample means.
- Background extraction asks for {"facts":[]} / {"plans":[]} and reads
  either shape, runs once a conversation has been quiet for 30 minutes, and
  stores health facts as proposals offered once at the next turn.
- Unanswered plan and fact proposals lapse after 14 days in the reminder
  sweep.
- Memory list API: POST saves one of the caller's own messages (the
  remember button), PATCH edits a fact's wording, GET reports source, origin
  and last use and never lists a waiting proposal.
- A structural guard keeps the egress check ahead of every read and the
  callers of both builders to the turn and the briefing.
2026-10-06 19:32:31 +02:00
Marc-André Bombeck edf6fffc32 test: admit the per-call reasoning level in the reasoning_effort guard
The key now appears twice in each client: the bound chain setting, sent only when the call carries no level, and the call's resolved level. The guard pins both, the condition in front of the bound value, and the absence of a literal.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 210becb28b 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.
2026-10-06 18:39:50 +02:00
Marc-André Bombeck bed47387de fix(ui): show text-field focus as a neutral border, not a purple ring
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.

text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
2026-10-05 21:08:48 +02:00
Marc-André Bombeck 130ace74ee fix(crypto): keep updatedAt when key rotation re-seals a value
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
2026-10-05 13:07:22 +02:00
Marc-André Bombeck 7d42cf7122 fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.

Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 0d6bac8c5c Merge remote-tracking branch 'origin/feat/coach-answers' into release/v1.40.2
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-10-05 07:16:55 +02:00
Marc-André Bombeck 57a889ef3c Merge remote-tracking branch 'origin/feat/coach-frame' into release/v1.40.2 2026-10-05 07:16:09 +02:00
Marc-André Bombeck a91e1bf0a7 Give each Coach answer one action row and keep its chips in its column
Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.

Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
2026-10-04 20:02:48 +02:00
Marc-André Bombeck 12153fe143 feat(coach): move conversations into a collapsible right panel
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.

The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.

The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.

Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
2026-10-04 20:00:07 +02:00
Marc-André Bombeck 13ea849188 perf(backup): load the backup payload schema on first use
backupPayloadSchema is built when validations/backup.ts is evaluated and
holds about 12 MB of heap. The restore job, the stored-backup readers and
the export writer import that module, and they sit on the boot path, so
every server process built the schema at start whether or not it ever
touched a backup.

The schema version, the compatibility check and the per-section summary
move to validations/backup-summary.ts, which never builds the schema. The
call sites that validate a file (upload, restore, the summary and download
routes, and the streamed reader's element check) load backup.ts with a
dynamic import at that moment; everything else takes only types. The
OpenAPI registry keeps its static import, since only the spec generator
loads it.

After warm-up on a production build under --max-old-space-size=524, the
used heap drops by about 29 MB, and the schema no longer appears in a heap
snapshot. A structural test refuses a new static value import of the
module outside the registry.
2026-10-04 18:23:14 +02:00
Marc-André Bombeck 006679ed9e Merge pull request #1145 from MBombeck/perf/dashboard-client
perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
2026-10-04 17:33:51 +02:00
Marc-André Bombeck 1b1da652d3 fix(monitoring): route Umami events to the endpoint the tracker was built with
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.

Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
2026-10-04 15:13:52 +02:00
Marc-André Bombeck 13b8920dd5 Merge remote-tracking branch 'origin/main' into feat/pulse-hourly-mean-complete 2026-10-04 14:54:53 +02:00
Marc-André Bombeck 06f210b56f Guard every pulse mean reader behind the day-mean helper
A structural test lists every file that takes a database or in-memory mean
over measurement values and names pulse, and requires each to use the shared
statistic, hand the type to a fold that does, or state why it never averages
pulse readings. A new plain mean over pulse, or a reader that drops the type,
fails it.
2026-10-04 11:39:33 +02:00
Marc-André Bombeck 24786f6549 Merge pull request #1130 from MBombeck/feat/provider-reasoning-effort
feat(ai): per-provider reasoning effort for Local and gateway models
2026-10-04 11:29:44 +02:00
Marc-André Bombeck befac59576 feat(ai): send a per-entry reasoning effort to Local and gateway providers
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.

- Every exported resolver binds the entry's value onto the client, the
  connection test included, keyed on the instance type so the operator
  provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
  compatReasoningEffort and GET reports them. A provider absent from
  the chain holds its value on a disabled entry; restating Default on a
  chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
  openai-compatible entries, refuses it on any other, and keeps the
  stored value when the body leaves it out. Its schema is now shared
  with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.

Refs #1126
2026-10-04 10:42:59 +02:00
Marc-André Bombeck b63fa317f3 fix(docker): hoist the canvas binary of the pinned version
Refs #1124. The store holds two @napi-rs/canvas versions, the pinned one
and an older one that pdf-parse's own pdfjs-dist pulls in. The runner
stage took the musl binary with `find | head -1`, got the old one, and
copied it into the new loader's directory, where the loader looks first.
Rendering still worked, but `loadImage` calls `Image.decode`, which the
old binary lacks, so every preview thumbnail failed with "image.decode is
not a function" on both architectures.

The builder now records the resolved canvas version and the runner hoists
that version by name, as it already does for pdfjs-dist. The build stops
when the binary it copies belongs to another version.
2026-10-04 10:37:23 +02:00
Marc-André Bombeck db9618ee7b Merge remote-tracking branch 'origin/main' into wip/v140-H 2026-10-04 00:43:10 +02:00
Marc-André Bombeck 4317eb91ca Make the rolling course anchor opt-in
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
2026-10-04 00:42:54 +02:00
Marc-André Bombeck 69bae8bdb4 Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.

The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
2026-10-03 23:46:40 +02:00
Marc-André Bombeck 4783711651 Refuse MCP and its OAuth endpoints while the encryption key does not match
apiHandler answers 503 encryption.key_mismatch before auth, but the remote
MCP endpoint and the OAuth authorize, token and register routes wrap in
withBackgroundEvent instead and never checked. An MCP write tool could seal
new rows under the wrong key, and those rows would open under it at the
next boot.

The refusal moves into one helper used by apiHandler, /mcp (same envelope)
and the OAuth routes (RFC 6749 temporarily_unavailable with the error code
as an extension member). A structural test fails when a route outside
apiHandler, or a server action, lacks the refusal in every exported handler
and is not on a short allowlist of routes that never touch the database.
2026-10-03 23:32:07 +02:00
Marc-André Bombeck 1494409209 Merge remote-tracking branch 'origin/main' into wip/v140-H
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/v137-wave-zero-fixtures.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-03 22:52:49 +02:00
Marc-André Bombeck a14174fe4d Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.

- Migration 0368 adds medication_courses and backfills one course per
  medication window (an end-only window starts on the creation day, or
  on its end day when that lies earlier); a medication without a window
  stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
  .../courses/{courseId} (owner-only); create seeds the first course
  from a window; the medication PUT edits the latest course and refuses
  a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
  previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
  between them; every compliance read selects the courses. A rolling
  cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
  the detail Lifecycle group lists the courses with start, edit and
  delete. The Coach compliance block, the MCP resource, the doctor
  report window and table, the clinician view and FHIR (one
  MedicationStatement per course with effectivePeriod and status) read
  the courses.
- Backup carries courses per medication and the restore writes them and
  re-projects; an older file derives the one course its window
  describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
2026-10-03 22:39:25 +02:00
Marc-André Bombeck ef0028c020 Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 22:16:29 +02:00
Marc-André Bombeck 9c2b57df33 Merge remote-tracking branch 'origin/main' into wip/v140-I 2026-10-03 22:00:18 +02:00
Marc-André Bombeck 8068626aa8 Merge remote-tracking branch 'origin/main' into wip/v140-K 2026-10-03 21:48:26 +02:00
Marc-André Bombeck 57a54061be Merge pull request #1112 from BernhardBuckel/fix/series-source-ladder
fix(series): follow the source priority ladder in the dense and raw series reads
2026-10-03 21:41:33 +02:00
Marc-André Bombeck dbbff339f6 test(symptoms): register the new surfaces with the export, destructive and success guards
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 4cb8118802 Let a person name their own medication categories
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.

- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
  admitted at MANAGE like the cycle symptom vocabulary) and owner-only
  PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
  medication filed under the category to OTHER in the same transaction
  and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
  the create and update bodies refuse a key that is not the caller's
  (422 medications.category.unknown), and list, detail, the insights
  medication tiles and the MCP medications resource carry categoryLabel.
  A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
  built-in rows with an inline add row; the list page gains a category
  filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
  restore writes them before the medications; wipe plan, encrypted
  column registry, rotation script, sharing guards and OpenAPI follow.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00
Marc-André Bombeck d6571c4e9c feat(symptoms): add person-defined symptoms and timestamped occurrences
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.

- Migration 0369 (idempotent): symptom_definitions and symptom_events,
  intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
  eight active definitions per record (a hidden one frees its slot,
  showing it again re-checks), hide or purge, events create/list/edit/
  delete, a new link must name an open episode of the same record
  (422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
  editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
  matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
  plan; both carried at both backup ends with occurrences nested in their
  definition, plaintext in a portable file and ciphertext in a recovery
  file, and a link to an episode the file lacks dropped and named
  (symptomEpisodeReference).
- OpenAPI regenerated.
2026-10-03 21:13:45 +02:00
Marc-André Bombeck eda5776886 Run document summaries and filing suggestions in the background
The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.

The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.

The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
2026-10-03 20:51:57 +02:00