From eeda21c7a14ff193962485d37c414b3b3796fa7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:15:48 +0200 Subject: [PATCH] refactor(coach): keep the table storage budget in the pure refs module The backup validation now reads the per-message table ceiling from results/refs, which imports nothing but types. Reading it from results/project pulled the tool executor and the message store into the snapshot builder's import graph, and the free-text sanitiser guard then walked files that never build a prompt. --- src/lib/ai/coach/persistence.ts | 17 ++++++- .../results/__tests__/chart-spec.test.ts | 2 +- src/lib/ai/coach/results/project.ts | 28 ------------ src/lib/ai/coach/results/refs.ts | 44 +++++++++++++++++-- .../turn/__tests__/dialog-frames.test.ts | 2 +- src/lib/ai/coach/turn/provenance.ts | 2 +- src/lib/validations/backup.ts | 2 +- 7 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 2822bacd9..be65d1539 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -23,7 +23,7 @@ import { coachStepSchema, } from "./stream-events"; import { COACH_CONVERSATION_TITLE_MAX } from "./types"; -import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/project"; +import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/refs"; import { isCheckupIntervalId, isSuggestedActionType, @@ -609,7 +609,20 @@ export async function recordProactiveNudge( // newest messages up to this cap cover the rendered window without the // unbounded per-open AES-decrypt cost. The response shape is unchanged — // the messages array still arrives oldest->newest (see the reverse below). -const CONVERSATION_MESSAGE_DETAIL_CAP = 200; +export const CONVERSATION_MESSAGE_DETAIL_CAP = 200; + +/** + * The assistant messages of a conversation written before `before`. The + * caller has already read the conversation owner-narrowed; this only counts. + */ +export function countAssistantMessagesBefore( + conversationId: string, + before: Date, +): Promise { + return prisma.coachMessage.count({ + where: { conversationId, role: "assistant", createdAt: { lt: before } }, + }); +} /** * Fetch one conversation + its messages, decrypting each body on diff --git a/src/lib/ai/coach/results/__tests__/chart-spec.test.ts b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts index 3b78d9997..586f71f95 100644 --- a/src/lib/ai/coach/results/__tests__/chart-spec.test.ts +++ b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts @@ -287,7 +287,7 @@ describe("deriveChartSpec: time series", () => { }); }); - it("leaves the reading count off the chart", () => { + it("leaves the reading count off the chart", () => { const spec = deriveChartSpec(daySeries("pulse", [60, 62], "bpm")); expect(spec && "series" in spec ? spec.series : []).not.toContain( "readings", diff --git a/src/lib/ai/coach/results/project.ts b/src/lib/ai/coach/results/project.ts index 5435780aa..bbf1b56f2 100644 --- a/src/lib/ai/coach/results/project.ts +++ b/src/lib/ai/coach/results/project.ts @@ -22,34 +22,6 @@ import { MAX_RESULTS_PER_TURN, isTurnResultRef } from "./refs"; /** At most this many rows reach a table; a year of days fits. */ export const RESULT_TABLE_MAX_ROWS = 400; -/** - * The at-rest ceiling for one message's tables, as JSON before encryption. - * Tables arrive trimmed to 400 rows; a message whose tables still exceed - * this keeps the leading tables that fit. - */ -export const RESULTS_MAX_BYTES = 128 * 1024; - -/** - * The tables a message keeps: the leading ones whose JSON fits - * `RESULTS_MAX_BYTES`, whole tables dropped from the end. Applied once, - * before the turn streams them, so the tables the person sees live are the - * tables a reload reads back, and the metadata never names a table the - * ciphertext does not hold. - */ -export function fitResultsToStorage( - results: readonly CoachResultTable[], -): CoachResultTable[] { - const kept = results.slice(0, MAX_RESULTS_PER_TURN); - while ( - kept.length > 0 && - new TextEncoder().encode(JSON.stringify(kept)).byteLength > - RESULTS_MAX_BYTES - ) { - kept.pop(); - } - return kept; -} - /** One tool call of this turn, as the loop settled it. */ export interface SettledToolCall { name: string; diff --git a/src/lib/ai/coach/results/refs.ts b/src/lib/ai/coach/results/refs.ts index f956c6b7c..08339ce46 100644 --- a/src/lib/ai/coach/results/refs.ts +++ b/src/lib/ai/coach/results/refs.ts @@ -7,7 +7,7 @@ * in the tool result it cites from. * - `m.r` — table `r` of an earlier assistant message of the same * conversation, `k` being that message's 1-based position among the - * assistant messages the turn loaded. The names are drawn once per turn + * conversation's assistant messages (stable however many are loaded). The names are drawn once per turn * from the conversation the turn already read (owner-narrowed), and * `show_result` resolves only against that same list, so a name can never * reach a table of another conversation or another account. @@ -17,7 +17,7 @@ * * Pure: no database, no provider. The reply guards import this module. */ -import type { CoachResultMeta } from "@/lib/ai/coach/types"; +import type { CoachResultMeta, CoachResultTable } from "@/lib/ai/coach/types"; /** At most this many tables per message (`r1`..`r6`). */ export const MAX_RESULTS_PER_TURN = 6; @@ -52,7 +52,7 @@ export function createResultRefAllocator(): ResultRefAllocator { /** One earlier assistant message that holds tables. */ export interface PriorResultTurn { messageId: string; - /** 1-based position among the conversation's loaded assistant messages. */ + /** 1-based position among the conversation's assistant messages. */ turnIndex: number; results: CoachResultMeta[]; } @@ -70,9 +70,15 @@ interface LoadedMessage { */ export function collectPriorResults( messages: ReadonlyArray, + /** + * Assistant messages written before the first loaded one, so an index is + * the message's position in the whole conversation, not in the window the + * turn happened to load. + */ + earlierAssistantMessages = 0, ): PriorResultTurn[] { const out: PriorResultTurn[] = []; - let turnIndex = 0; + let turnIndex = earlierAssistantMessages; for (const message of messages) { if (message.role !== "assistant") continue; turnIndex += 1; @@ -166,3 +172,33 @@ export function stripResultRefs(prose: string): { referenced, }; } + +// ── What a message keeps ────────────────────────────────────────────────── + +/** + * The at-rest ceiling for one message's tables, as JSON before encryption. + * Tables arrive trimmed to 400 rows; a message whose tables still exceed + * this keeps the leading tables that fit. + */ +export const RESULTS_MAX_BYTES = 128 * 1024; + +/** + * The tables a message keeps: the leading ones whose JSON fits + * `RESULTS_MAX_BYTES`, whole tables dropped from the end. Applied once, + * before the turn streams them, so the tables the person sees live are the + * tables a reload reads back, and the metadata never names a table the + * ciphertext does not hold. + */ +export function fitResultsToStorage( + results: readonly CoachResultTable[], +): CoachResultTable[] { + const kept = results.slice(0, MAX_RESULTS_PER_TURN); + while ( + kept.length > 0 && + new TextEncoder().encode(JSON.stringify(kept)).byteLength > + RESULTS_MAX_BYTES + ) { + kept.pop(); + } + return kept; +} diff --git a/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts index c3499db95..6694b343b 100644 --- a/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts +++ b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts @@ -21,7 +21,7 @@ import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; import { emitReply } from "../sse"; import { assembleTurnDialog, buildTurnProvenance } from "../provenance"; -import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/project"; +import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs"; import type { ModelOutcome } from "../model"; import type { GuardedReply } from "../reply-guards"; diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts index 0f223a469..9df7b0098 100644 --- a/src/lib/ai/coach/turn/provenance.ts +++ b/src/lib/ai/coach/turn/provenance.ts @@ -28,7 +28,7 @@ import { import { numberFollowUps } from "@/lib/ai/coach/follow-ups/catalog"; import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue"; import type { CoachPrefs } from "@/lib/validations/coach-prefs"; -import { fitResultsToStorage } from "@/lib/ai/coach/results/project"; +import { fitResultsToStorage } from "@/lib/ai/coach/results/refs"; import type { ModelOutcome } from "./model"; import type { GuardedReply } from "./reply-guards"; diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index f8a9aa1af..333beab35 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -20,7 +20,7 @@ * a useful summary for them. */ import { z } from "zod/v4"; -import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/project"; +import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs"; import { AllergyCategory, AllergySeverity,