feat(coach): give the turn the memory block, its recalled facts and a pending proposal

Build the memory block for the providers the turn sends to, send it on
both paths, keep the facts it recalled in the owner's trail, and offer a
health fact the background found as the answer's one note. Every turn
enqueues the memory refresh; the job's debounce and singleton key decide
when it runs. A remembered fact points back to the stored user message.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:48:55 +02:00
1 parent f46e0960fb
commit de5b315999
10 files changed
+176 -53

No files matched your search

+5
View File
@@ -31,6 +31,8 @@ import {
import {
PLAN_REVIEW_DAYS,
REMEMBER_FACT_MAX_CHARS,
} from "@/lib/ai/coach/memory/shared";
import {
proposePlanFromTool,
rememberFactFromTool,
} from "@/lib/ai/coach/memory/contract";
@@ -200,6 +202,8 @@ export interface DialogToolContext {
locale: Locale;
/** The person's current message: the only text a fact may come from. */
userMessage: string;
/** The stored id of that message, which a remembered fact points back to. */
userMessageId?: string;
/** What the record holds; null on the no-tools path. */
inventory: InventoryEntry[] | null;
/**
@@ -319,6 +323,7 @@ export async function runDialogTool(args: {
userId: ctx.userId,
conversationId: ctx.conversationId,
userMessage: ctx.userMessage,
...(ctx.userMessageId ? { userMessageId: ctx.userMessageId } : {}),
call: parsed.data,
});
if (outcome.kind === "declined") {
+7 -1
View File
@@ -228,6 +228,12 @@ export async function runCoachToolLoop(args: {
locale?: Locale;
/** v1.41 — what the dialog tools need. Absent: they are declined. */
dialog?: DialogToolContext;
/**
* v1.41 — a note the answer already carries before any round (a health
* fact the background found, offered once). It is the answer's one note:
* `remember_fact` is declined beside it.
*/
initialMemoryNote?: CoachMemoryNote;
/** v1.41 — the figures read so far, for screening a checkpoint. */
checkpointScreen?: { userMessage: string };
}): Promise<CoachToolLoopResult> {
@@ -276,7 +282,7 @@ export async function runCoachToolLoop(args: {
const toolTrace: CoachToolTrace[] = [];
const toolResults: CoachToolResult[] = [];
const declinedClarifications: CoachClarification[] = [];
let memoryNote: CoachMemoryNote | undefined;
let memoryNote: CoachMemoryNote | undefined = args.initialMemoryNote;
let planProposal: CoachPlanProposal | undefined;
let callCount = 0;
let stopReason: CoachStopReason | null = null;
@@ -131,6 +131,7 @@ function persistedProvenance(): CoachProvenance {
beforeEach(() => {
for (const fn of Object.values(m)) fn.mockReset();
m.persistUserTurn.mockResolvedValue({ messageId: "m-user" });
m.resolveTurnConversation.mockResolvedValue({
conversation: {
conversationId: "c1",
@@ -238,6 +238,7 @@ function modelPath(prefs = DEFAULT_COACH_PREFS) {
beforeEach(() => {
for (const fn of Object.values(m)) fn.mockReset();
m.persistUserTurn.mockResolvedValue({ messageId: "m-user" });
m.readFollowUpHistory.mockResolvedValue({
today: "2026-09-27",
firstDate: { bp: "2024-01-01" },
+13 -12
View File
@@ -177,18 +177,19 @@ export async function assembleTurnContext(args: {
// Fact capture must never break the chat turn; the >TURN_CAP LLM
// extraction remains as the catch-all on long conversations.
});
if (turnContext.historyElided) {
void enqueueCoachMemoryRefresh({
conversationId,
userId,
// Coach memory prose is composed in de/en only — it is MODEL-FACING
// context (a rolling conversation summary + extracted durable facts),
// not user-facing prose, so English is the correct target for every
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
// binary composed and keyed a French account's memory in German.
locale: instructionLocale(locale),
});
}
// v1.41 — every turn enqueues the refresh; the job waits for the
// conversation to go quiet and runs once per conversation (its debounce
// and singleton key), so a short chat is remembered too.
void enqueueCoachMemoryRefresh({
conversationId,
userId,
// Coach memory prose is composed in de/en only — it is MODEL-FACING
// context (a rolling conversation summary + extracted durable facts),
// not user-facing prose, so English is the correct target for every
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
// binary composed and keyed a French account's memory in German.
locale: instructionLocale(locale),
});
// A workout launch is an optional narrowing of Coach, not permission to
// bypass the workouts module. Disabled modules contribute no read and no
+4 -2
View File
@@ -163,10 +163,12 @@ export async function resolveTurnConversation(args: {
export async function persistUserTurn(
conversationId: string,
message: string,
): Promise<void> {
await appendMessage({
): Promise<{ messageId: string }> {
const stored = await appendMessage({
conversationId,
role: "user",
content: message,
});
// v1.41 — the id a remembered fact points back to.
return { messageId: stored.id };
}
+15 -10
View File
@@ -282,6 +282,8 @@ export async function runTurnModel(args: {
priorResults?: PriorResultTurn[];
/** v1.41 — the person's message, the only source of a remembered fact. */
message: string;
/** v1.41 — that message's stored id. */
userMessageId?: string;
/** v1.41 — the reasoning the turn asks for, already resolved. */
reasoning?: TurnReasoning;
/** v1.41 — who pays: fixes the turn's budget. */
@@ -333,8 +335,13 @@ export async function runTurnModel(args: {
userId,
conversationId,
locale,
// The block re-runs the wire egress check for exactly these.
providerTypes: chain.map((entry) => entry.providerType),
}).catch(() => null);
if (memory) {
recorder.setRecalled(memory.recalled);
}
if (memory && memory.factIds.length + memory.planIds.length > 0) {
const count = memory.factIds.length + memory.planIds.length;
const id = activity.start({
phase: "memory",
@@ -365,12 +372,8 @@ export async function runTurnModel(args: {
reach: ctx.reach,
prior: args.priorResults ?? [],
});
const requestWith = (tableRules: boolean) => {
// `memoryBlock` is the memory package's parameter; held in a
// variable so the request builds whether or not it is declared yet.
const request: Parameters<typeof buildCoachToolRequest>[0] & {
memoryBlock?: string;
} = {
const requestWith = (tableRules: boolean) =>
buildCoachToolRequest({
systemPrompt: ctx.systemPrompt,
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
buildDialogAddenda(locale, { tableRules }),
@@ -384,9 +387,7 @@ export async function runTurnModel(args: {
transcript: turnContext.transcript,
languageName: LANGUAGE_NAMES[locale],
...(memory ? { memoryBlock: memory.text } : {}),
};
return buildCoachToolRequest(request);
};
});
// The recheck and follow-up rules are about tables: they ride the
// prompt once the conversation holds one, or from the round after this
// turn produced its first.
@@ -445,11 +446,15 @@ export async function runTurnModel(args: {
...(args.reasoning ? { reasoning: args.reasoning } : {}),
activity,
locale,
...(memory?.pendingProposal
? { initialMemoryNote: memory.pendingProposal }
: {}),
dialog: {
userId,
conversationId,
locale,
userMessage: args.message,
...(args.userMessageId ? { userMessageId: args.userMessageId } : {}),
inventory: inventory.entries,
conversationWindowSet: args.conversationWindowSet,
},
@@ -698,7 +703,7 @@ export async function runTurnModel(args: {
activity: recorder,
toolClarification: null,
declinedClarifications: [],
memoryNote: null,
memoryNote: memory?.pendingProposal ?? null,
planProposal: null,
interimSent: false,
};
+5 -1
View File
@@ -149,7 +149,10 @@ async function runTurn(
...(clarifiedLine ? [clarifiedLine] : []),
];
await persistUserTurn(workingConversationId, message);
const { messageId: userMessageId } = await persistUserTurn(
workingConversationId,
message,
);
const ctx = await assembleTurnContext({
userId,
@@ -219,6 +222,7 @@ async function runTurn(
turnHints,
priorResults: conversation.priorResults,
message,
userMessageId,
reasoning,
payer,
conversationWindowSet: input.scope?.window !== undefined,
+2
View File
@@ -49,6 +49,8 @@ import { createSseStream } from "@/lib/sse/create-stream";
import {
COACH_MEMORY_KEYS,
COACH_PLAN_KEYS,
} from "@/lib/ai/coach/memory/shared";
import {
decideFactProposal,
decidePlanProposal,
} from "@/lib/ai/coach/memory/contract";