Merge remote-tracking branch 'origin/v141/e-memory' into v141/b-loop

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:39:10 +02:00
commit f46e0960fb
51 files changed
+6021 -823

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();