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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:32:31 +02:00
1 parent 210becb28b
commit acec87f02c
40 files changed
+5178 -678

No files matched your search

@@ -332,7 +332,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// being available (the GDPR reason), and a timestamp on one's own row is
// data too.
"src/app/api/insights/coach/seen/route.ts",
"src/app/api/insights/coach/facts/route.ts",
// `insights/coach/facts/route.ts` is not listed, by the rule below: it
// lists and erases without the Coach gate but keeps it on POST (the
// remember button saves to the Coach's memory, which is Coach use).
"src/app/api/insights/coach/facts/[id]/route.ts",
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
+12 -1
View File
@@ -36,6 +36,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { clampReviewDays } from "@/lib/ai/coach/memory/propose-plan";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
@@ -102,15 +103,25 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
// the current row first so an explicitly-pinned review date is never
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
// v1.41 — a plan the Coach proposed in a turn carries its review window as
// the distance from the proposal to its `reviewDate`; taking it on here
// counts that window from now, exactly like the tap under the answer
// (`decidePlanProposal`).
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
const current = await prisma.coachPlan.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { reviewDate: true },
select: { reviewDate: true, status: true, createdAt: true },
});
if (current && current.reviewDate === null) {
data.reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
);
} else if (current?.reviewDate && current.status === "proposed") {
const days = clampReviewDays(
(current.reviewDate.getTime() - current.createdAt.getTime()) /
86_400_000,
);
data.reviewDate = new Date(Date.now() + days * 86_400_000);
}
}
+84 -2
View File
@@ -1,8 +1,15 @@
/**
* PATCH /api/insights/coach/facts/[id] — edit one Coach fact's wording.
* DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact.
*
* v1.11.1 — the per-fact "forget this one thing" delete for the durable
* Coach facts surface.
* Coach facts surface. It is also the "Undo" under a note the Coach saved in
* a turn (v1.41).
*
* v1.41 — PATCH rewrites the fact's text (re-encrypted; the category and the
* source stay). Only a listed fact can be edited: a proposal still waiting
* for the person's answer is not one, and answers 404 like an unknown id.
* Editing is the person's own data, so no Coach or AI gate.
*
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
* `where: { id, userId, deletedAt: null }` rather than `update`. A
@@ -18,14 +25,89 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { coachFactPatchSchema } from "@/lib/validations/coach-fact";
interface RouteCtx {
params: Promise<{ id: string }>;
}
const PATCH_RATE_LIMIT = 40;
const PATCH_WINDOW_MS = 60_000;
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`coach-facts:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 2 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachFactPatchSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const text = parsed.data.fact.replace(/\s+/g, " ");
// `updateMany` scoped by id, owner and liveness: an unknown, cross-user,
// deleted or still-proposed id is a 0-count no-op, never a P2025 throw.
const { count } = await prisma.coachFact.updateMany({
where: {
id,
userId: user.id,
deletedAt: null,
source: { not: PROPOSED_FACT_SOURCE },
},
data: { factEncrypted: encryptToBytes(text) },
});
if (count === 0) {
// Indistinguishable from "owned by someone else" — never reveal which.
return apiError("Fact not found", 404);
}
const row = await prisma.coachFact.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { id: true, category: true, source: true, updatedAt: true },
});
if (!row) return apiError("Fact not found", 404);
annotate({
action: { name: "coach.facts.updated" },
meta: { category: row.category },
});
return apiSuccess({
fact: {
id: row.id,
category: row.category,
text,
source: row.source,
updatedAt: row.updatedAt.toISOString(),
},
});
});
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
@@ -0,0 +1,262 @@
/**
* v1.41 — the memory list's write half: the remember button (POST), editing
* a fact (PATCH), and a GET that never lists a proposal still waiting for
* the person's answer.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
coachFact: {
findMany: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/ai/coach/memory/remember", () => ({
rememberMessageAsFact: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 10,
resetAt: new Date(),
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
// The capability gate answers "unavailable" throughout: every verb must
// still work, and must not even ask.
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(async () => {
throw new Error("the coach capability was consulted");
}),
getAiCapability: vi.fn(async () => ({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
})),
}));
// Mock the codec so the test never needs an encryption key. The "row"
// carries a tagged Uint8Array and the mock maps it back to a string;
// a sentinel buffer triggers a throw to exercise the fail-closed skip.
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn((buf: Uint8Array) => Buffer.from(buf).toString()),
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(s))),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
},
};
const callGet = GET as unknown as () => Promise<Response>;
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
const callPatch = PATCH as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
function req(method: string, body: unknown): NextRequest {
return new NextRequest("http://localhost/api/insights/coach/facts", {
method,
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
});
describe("GET /api/insights/coach/facts (v1.41)", () => {
it("never lists a proposal and returns where each fact came from", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: new Uint8Array(Buffer.from("75 kg by December")),
confidence: 80,
source: "coach",
sourceConversationId: "c1",
sourceMessageId: "m1",
lastUsedAt: new Date("2026-10-05T10:00:00Z"),
createdAt: new Date("2026-10-01T10:00:00Z"),
updatedAt: new Date("2026-10-01T10:00:00Z"),
},
] as never);
const res = await callGet();
const body = (await res.json()) as {
data: { facts: Array<Record<string, unknown>> };
};
expect(body.data.facts[0]).toMatchObject({
id: "f1",
text: "75 kg by December",
source: "coach",
sourceMessageId: "m1",
lastUsedAt: "2026-10-05T10:00:00.000Z",
});
const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.where as Record<string, unknown>;
expect(where.source).toEqual({ not: "proposed" });
});
});
describe("POST /api/insights/coach/facts", () => {
it("remembers the caller's own message by id", async () => {
vi.mocked(rememberMessageAsFact).mockResolvedValue({
id: "f9",
category: "medication",
text: "I take ramipril",
source: "user",
created: true,
});
const res = await callPost(req("POST", { messageId: "um1" }));
expect(res.status).toBe(201);
expect(rememberMessageAsFact).toHaveBeenCalledWith({
userId: "user-1",
messageId: "um1",
});
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toEqual({
fact: {
id: "f9",
category: "medication",
text: "I take ramipril",
source: "user",
},
created: true,
});
});
it("never accepts text or a userId in the body", async () => {
const res = await callPost(
req("POST", { messageId: "um1", fact: "anything", userId: "x" }),
);
expect(res.status).toBe(422);
expect(rememberMessageAsFact).not.toHaveBeenCalled();
});
it("is a 404 for a message the caller does not own", async () => {
vi.mocked(rememberMessageAsFact).mockResolvedValue(null);
const res = await callPost(req("POST", { messageId: "foreign" }));
expect(res.status).toBe(404);
});
it("is refused while the Coach module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
const res = await callPost(req("POST", { messageId: "um1" }));
expect(res.status).toBe(403);
expect(rememberMessageAsFact).not.toHaveBeenCalled();
});
});
describe("PATCH /api/insights/coach/facts/[id]", () => {
const ctx = { params: Promise.resolve({ id: "f1" }) };
it("rewrites the text, re-encrypted, scoped to the caller's live facts", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({
id: "f1",
category: "goal",
source: "coach",
updatedAt: new Date("2026-10-06T10:00:00Z"),
} as never);
const res = await callPatch(
req("PATCH", { fact: " Wants 74 kg by December " }),
ctx,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: Record<string, unknown>;
data: { factEncrypted: Uint8Array };
};
expect(arg.where).toEqual({
id: "f1",
userId: "user-1",
deletedAt: null,
source: { not: "proposed" },
});
expect(Buffer.from(arg.data.factEncrypted).toString()).toBe(
"Wants 74 kg by December",
);
expect(Object.keys(arg.data)).toEqual(["factEncrypted"]);
});
it("is a 404 for an unknown, foreign or proposed id", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callPatch(req("PATCH", { fact: "Something new" }), ctx);
expect(res.status).toBe(404);
});
it("refuses an empty or over-long fact, or extra keys", async () => {
for (const body of [
{ fact: "" },
{ fact: "x".repeat(161) },
{ fact: "fine text", category: "condition" },
]) {
const res = await callPatch(req("PATCH", body), ctx);
expect(res.status).toBe(422);
}
expect(prisma.coachFact.updateMany).not.toHaveBeenCalled();
});
});
+105 -5
View File
@@ -1,12 +1,23 @@
/**
* GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts.
* POST /api/insights/coach/facts — remember one of the caller's own Coach
* messages (the remember button).
* DELETE /api/insights/coach/facts — "forget all": soft-delete every active
* fact for the caller.
*
* v1.11.1 — the GDPR / "forget what you know about me" surface for the
* durable Coach facts the assistant extracts during conversations. Facts
* are server-extracted, not user-authored, so this surface is read +
* delete only (no POST / PATCH).
* durable Coach facts. v1.41 — also the Coach's one memory list: each fact
* says where it came from (`source`), when it was last used in a turn and
* which message it came out of; the person edits a fact through
* `PATCH …/facts/{id}`. A health fact still waiting for the person's answer
* (`source: "proposed"`) is not listed: nothing the person has not agreed to
* appears as something the Coach knows. "Forget all" clears those too.
*
* POST names a message, never text: the server reads the caller's own stored
* message, files it with the medication / condition lexicon (anything else is
* `context`) and saves it as `source: "user"`. The tap is the person's
* confirmation, so a health message is saved too. A message the caller does
* not own, or an assistant message, is a 404.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller
* can only ever see or clear their own facts. The fact text is decrypted
@@ -20,16 +31,35 @@
* Erasure that depends on the feature being on is no erasure.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { coachFactCreateSchema } from "@/lib/validations/coach-fact";
// The remember button is one tap per message; the limit only caps a runaway
// client loop, like the plan mutations.
const CREATE_RATE_LIMIT = 40;
const CREATE_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rows = await prisma.coachFact.findMany({
where: { userId: user.id, deletedAt: null },
where: {
userId: user.id,
deletedAt: null,
source: { not: PROPOSED_FACT_SOURCE },
},
// Highest-confidence first, then newest — mirrors the injection
// ordering so the management list reads in the same priority the
// assistant actually weights the facts.
@@ -39,7 +69,12 @@ export const GET = apiHandler(async () => {
category: true,
factEncrypted: true,
confidence: true,
source: true,
sourceConversationId: true,
sourceMessageId: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
},
});
@@ -48,7 +83,12 @@ export const GET = apiHandler(async () => {
category: string;
text: string;
confidence: number;
source: string;
sourceConversationId: string | null;
sourceMessageId: string | null;
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}> = [];
for (const row of rows) {
@@ -65,7 +105,12 @@ export const GET = apiHandler(async () => {
category: row.category,
text,
confidence: row.confidence,
source: row.source,
sourceConversationId: row.sourceConversationId,
sourceMessageId: row.sourceMessageId,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
@@ -77,6 +122,61 @@ export const GET = apiHandler(async () => {
return apiSuccess({ facts });
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// Saving to the Coach's memory is Coach use, like confirming a plan; it is
// not model work, so no AI gate.
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`coach-facts:create:${user.id}`,
CREATE_RATE_LIMIT,
CREATE_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = coachFactCreateSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const remembered = await rememberMessageAsFact({
userId: user.id,
messageId: parsed.data.messageId,
});
if (!remembered) {
// Indistinguishable from "owned by someone else" — never reveal which.
return apiError("Message not found", 404);
}
annotate({
action: { name: "coach.facts.created" },
meta: { category: remembered.category, created: remembered.created },
});
return apiSuccess(
{
fact: {
id: remembered.id,
category: remembered.category,
text: remembered.text,
source: remembered.source,
},
created: remembered.created,
},
remembered.created ? 201 : 200,
);
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();