feat(coach): capture and resurface "remind me" reminders

Capture the casual "remember this / remind me about X" utterance the Coach
used to drop. The model emits a provider-neutral `---REMEMBER---` sentinel
that the chat route strips inline on EVERY turn — so a short conversation,
where the request usually happens, no longer waits for the long-chat memory
worker. The note is the user's own explicit request, so it is stored
silently as active; a validated closed grammar (ISO date, relative +Nd/+Nw,
or a context cue) sets the trigger, and a malformed block is dropped.

Surface the stored reminders two ways. A snapshot recall sub-block injects
near, overdue, and already-surfaced reminders so the Coach references them
unprompted next turn. A daily sweep flips overdue reminders to due and
mints a one-off review reminder from a plan whose review date has passed,
finally activating the plan review-date column. In-app surfacing only here;
push delivery follows later.

Also generalise the propose-then-confirm action card: a closed
`---SUGGEST-ACTION---` allowlist (a preventive-care item or a reminder note)
rides an additive SSE frame. Nothing is created until the user confirms.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:38:04 +02:00
1 parent 9b61769a32
commit 27a8a59d67
9 files changed
+961 -3

No files matched your search

+72 -1
View File
@@ -93,6 +93,16 @@ import {
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
import {
parseRememberSentinel,
captureReminderFromSentinel,
buildRememberAddendum,
} from "@/lib/ai/coach/reminders";
import {
parseSuggestAction,
buildSuggestActionAddendum,
type CoachSuggestedAction,
} from "@/lib/ai/coach/suggest-action";
import {
parseCoachPrefs,
DEFAULT_REMINDER_SUGGESTION_PREFS,
@@ -371,7 +381,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// v1.16.0 — composed self-context: structured questionnaire fields
// plus age/gender merged in from the User profile.
const aboutMe = await getSelfContextTextForUser(userId, locale);
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
// v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so
// the two memory/action clauses are appended at assembly time here: one
// teaches the model to emit `---REMEMBER---` (durable "remind me" capture),
// one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both
// are provider-neutral sentinels stripped from the prose before it streams.
const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`;
const allTurns: CoachTurn[] = [
...priorTurns,
{ role: "user", content: message },
@@ -755,6 +770,39 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
const suggestParse = parseSuggestReminder(proseAfterStrip);
let replyText = suggestParse.prose.trim() || proseAfterStrip;
// v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the
// reminder INLINE on this turn (not in the >20-turn memory worker), so a
// casual "remind me about X" in a SHORT chat is no longer lost. The note is
// the user's OWN explicit request → captured silently as `active` (no confirm
// gate); the model acknowledges it in prose. A missing note / invalid `when`
// drops the block (the user never sees the raw marker). Fire-and-forget: the
// capture write must never break the chat turn.
const rememberParse = parseRememberSentinel(replyText, new Date());
replyText = rememberParse.prose.trim() || replyText;
if (rememberParse.reminder) {
const capture = rememberParse.reminder;
void captureReminderFromSentinel({
userId,
conversationId: workingConversationId,
parsed: capture,
}).catch(() => {
// Reminder capture is best-effort; never sink the turn.
});
} else if (rememberParse.malformed) {
annotate({
action: { name: "coach.reminder.capture_malformed" },
meta: { conversationId: workingConversationId },
});
}
// v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the
// generalised confirm→apply moat). The model names ONE action from the closed
// allowlist (`checkup.create` / `reminder.note`); the card surfaces additively
// and NOTHING is created until the user taps confirm (the entity is built
// server-side, field-by-field, by `POST /api/coach/suggested-actions`).
const actionParse = parseSuggestAction(replyText);
replyText = actionParse.prose.trim() || replyText;
// v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
// reply, before persistence and streaming. The inbound `detectRefusal`
// guards the user's message; this guards the model's reply for a
@@ -888,8 +936,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
});
}
}
// v1.22 (F6) — surface the confirm-card action when the turn was not blocked.
// Additive: the prose already stands alone; the card only offers the one-tap
// confirm. Nothing is created server-side until the user taps it.
let surfacedAction: CoachSuggestedAction | null = null;
if (!outbound.block && actionParse.action) {
surfacedAction = actionParse.action;
annotate({
action: { name: "coach.action.suggested" },
meta: { actionType: actionParse.action.actionType },
});
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
...(surfacedAction ? { suggestedAction: surfacedAction } : {}),
// v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the
// key-values from the discarded reply must not ride along as provenance.
...(!outbound.block && sentinel.keyValues.length > 0
@@ -1018,6 +1079,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
);
}
// v1.22 (F6) — additive `suggestedAction` frame. Older clients ignore it;
// newer ones render the generic one-tap confirm card.
if (surfacedAction) {
controller.enqueue(
encodeFrame({
type: "suggestedAction",
suggestedAction: surfacedAction,
}),
);
}
// v1.18.9 — additive `usage` envelope on the `done` frame so the client
// can paint the quiet per-message token footer the instant the stream
// closes. Server-authoritative: the client renders these numbers, never
+29 -2
View File
@@ -39,7 +39,12 @@ import {
} from "@/lib/insights/narrative/period-narrative";
import type { BaselineProfile } from "@/lib/insights/derived";
import { buildCoachFactsBlock } from "./facts";
import { buildCoachPlansBlock, type CoachPlanInjectEntry } from "./plans";
import {
buildCoachPlansBlock,
buildCoachRemindersBlock,
type CoachPlanInjectEntry,
type CoachReminderInjectEntry,
} from "./plans";
/** The period the rolling profile recalls — month is the high-signal beat. */
const MEMORY_PERIOD: NarrativePeriod = "month";
@@ -85,6 +90,13 @@ export interface CoachMemoryBlock {
* still awaiting the user's confirmation. Absent when none.
*/
plans?: CoachPlanInjectEntry[];
/**
* v1.22 (B2/B3) — the user's near/overdue or already-surfaced episodic
* reminders ("remind me about X"), top-N soonest first. Lets the Coach
* reference them unprompted next turn — the progress-reflection reflex.
* Absent when none.
*/
reminders?: CoachReminderInjectEntry[];
}
/** Pull the headline + driver recall off the latest period narrative. */
@@ -185,11 +197,25 @@ export async function buildCoachMemoryBlock(
plans = undefined;
}
// Sub-source 5 (v1.22 B2/B3): the user's near/overdue or surfaced episodic
// reminders. Fault-isolated like the others; the read half lives in plans.ts
// alongside the plan-injection block.
let reminders: CoachReminderInjectEntry[] | undefined;
try {
const remindersBlock = await buildCoachRemindersBlock(userId, now);
if (remindersBlock && remindersBlock.reminders.length > 0) {
reminders = remindersBlock.reminders;
}
} catch {
reminders = undefined;
}
if (
!priorNarrative &&
Object.keys(trendMemory).length === 0 &&
!facts &&
!plans
!plans &&
!reminders
) {
return null;
}
@@ -198,5 +224,6 @@ export async function buildCoachMemoryBlock(
if (priorNarrative) block.priorNarrative = priorNarrative;
if (facts) block.facts = facts;
if (plans) block.plans = plans;
if (reminders) block.reminders = reminders;
return block;
}
+93
View File
@@ -39,6 +39,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import { annotate } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import { REMINDERS_INJECT_TOP_N } from "./reminders";
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
export const COACH_PLAN_STATUSES = [
@@ -60,6 +61,7 @@ const RECENT_TURNS_CAP = 10;
type RunCompletionFn = typeof runStatusCompletion;
type PrismaLike = Pick<typeof prisma, "coachPlan" | "coachConversation">;
type ReminderPrismaLike = Pick<typeof prisma, "coachReminder">;
interface ExtractOpts {
runCompletion?: RunCompletionFn;
@@ -462,3 +464,94 @@ export async function buildCoachPlansBlock(
if (plans.length === 0) return null;
return { plans };
}
// ---------------------------------------------------------------------------
// v1.22 (B2/B3) — episodic reminder recall sub-block
// ---------------------------------------------------------------------------
/**
* How far ahead of `now` an active reminder enters the recall block. A
* reminder due within this horizon (or already overdue) is "near" enough that
* the Coach should reference it unprompted on the next message; a reminder due
* far in the future stays dormant until its window approaches.
*/
const REMINDER_RECALL_HORIZON_DAYS = 3;
const REMINDER_RECALL_HORIZON_MS = REMINDER_RECALL_HORIZON_DAYS * 86_400_000;
/** One reminder as the snapshot recall block carries it. */
export interface CoachReminderInjectEntry {
note: string;
metric?: string;
/** ISO due moment, when the reminder is date-triggered. */
dueAt?: string;
/** Lifecycle status (active | due | surfaced) for the model's framing. */
status: string;
}
interface InjectReminderRow {
noteEncrypted: Uint8Array;
metric: string | null;
dueAt: Date | null;
status: string;
updatedAt: Date;
}
/**
* Build the recall sub-block: the reminders the Coach should reference next
* time — anything already `due`/`surfaced` (the sweep flipped it and the user
* has not resolved it) plus `active` reminders whose `dueAt` is near or overdue.
* A recall-only `active` note (no `dueAt`) is also surfaced so the Coach honours
* the user's explicit "remember this" even without a fire moment. Decrypt is
* fault-isolated — an undecryptable row is skipped, never thrown. Returns `null`
* when the user has nothing to recall.
*
* This is the read half of the episodic memory type, mirroring
* `buildCoachPlansBlock`; the write half lives in `reminders.ts`.
*/
export async function buildCoachRemindersBlock(
userId: string,
now: Date,
opts?: { prisma?: ReminderPrismaLike },
): Promise<{ reminders: CoachReminderInjectEntry[] } | null> {
const db = opts?.prisma ?? prisma;
const horizon = new Date(now.getTime() + REMINDER_RECALL_HORIZON_MS);
const rows = (await db.coachReminder.findMany({
where: {
userId,
deletedAt: null,
OR: [
// Already surfaced by the sweep and not yet resolved.
{ status: { in: ["due", "surfaced"] } },
// Active + near/overdue date trigger.
{ status: "active", dueAt: { lte: horizon } },
// Active recall-only note (no fire moment) — honour the explicit ask.
{ status: "active", dueAt: null },
],
},
// Soonest-due first, then most-recently touched.
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
take: REMINDERS_INJECT_TOP_N * 3,
select: {
noteEncrypted: true,
metric: true,
dueAt: true,
status: true,
updatedAt: true,
},
})) as InjectReminderRow[];
const reminders: CoachReminderInjectEntry[] = [];
for (const r of rows) {
if (reminders.length >= REMINDERS_INJECT_TOP_N) break;
const note = decryptOrNull(r.noteEncrypted);
if (note === null) continue;
const entry: CoachReminderInjectEntry = { note, status: r.status };
if (r.metric) entry.metric = r.metric;
if (r.dueAt) entry.dueAt = r.dueAt.toISOString();
reminders.push(entry);
}
if (reminders.length === 0) return null;
return { reminders };
}
+319
View File
@@ -0,0 +1,319 @@
/**
* v1.22 (B2/B6) — Coach episodic reminder memory: capture + grammar.
*
* The missing memory type between `CoachFact` (durable traits) and `CoachPlan`
* (standing if-then habits): a time-anchored "bring this back later" intent the
* user explicitly asked the Coach to remember ("remind me about my sleep next
* week"). Captured inline on EVERY turn via the provider-neutral
* `---REMEMBER---` sentinel — so a casual "remember this" in a SHORT chat is no
* longer lost waiting for the >20-turn memory worker.
*
* Friction tier (matches the design): a plain note the user EXPLICITLY asked
* for is captured silently as `status: "active"` — confirming "should I remember
* what you just told me to remember?" is the annoying anti-pattern. The
* propose→confirm rule stays where it belongs: durable traits/plans the model
* INFERRED (those still flow through the extractor + a confirm card).
*
* Defence-in-depth mirrors `suggest-reminder.ts` / `keyvalues.ts`: a byte cap on
* the payload, a single block kept, the `when` validated against a CLOSED
* grammar (ISO date / relative `+Nd`-`+Nw` / a context-cue enum), the metric
* validated against a loose key pattern, and a malformed block dropped (the user
* never sees the raw marker). The note TEXT is encrypted via `bytes-codec.ts`
* (the same AES-256-GCM codec as `CoachPlan`); `metric`, `status`, the trigger
* fields and the dates stay plain so the sweep + picker filter without a decrypt.
*
* Server-only — the capture path reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { encryptToBytes } from "./bytes-codec";
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
export const COACH_REMINDER_STATUSES = [
"proposed",
"active",
"due",
"surfaced",
"done",
"dismissed",
] as const;
export type CoachReminderStatus = (typeof COACH_REMINDER_STATUSES)[number];
/** App-side closed trigger-kind enum (matches the schema column). */
export const COACH_REMINDER_TRIGGER_KINDS = ["date", "context"] as const;
export type CoachReminderTriggerKind =
(typeof COACH_REMINDER_TRIGGER_KINDS)[number];
/**
* Closed context-cue enum. A `context` reminder fires when the cue is satisfied
* (evaluated by the daily sweep against persisted rows). The set is deliberately
* small and deterministic — "next time you log X" / "next time you open the app".
*/
export const COACH_REMINDER_CONTEXT_CUES = [
"NEXT_BP_LOGGED",
"NEXT_WEIGHT_LOGGED",
"NEXT_SLEEP_LOGGED",
"NEXT_APP_OPEN",
] as const;
export type CoachReminderContextCue =
(typeof COACH_REMINDER_CONTEXT_CUES)[number];
/** Provenance of a reminder row (matches the schema `source` column). */
export const COACH_REMINDER_SOURCES = [
"sentinel",
"extractor",
"manual",
"action",
] as const;
export type CoachReminderSource = (typeof COACH_REMINDER_SOURCES)[number];
/** Per-field caps (mirror the Zod gate + the prompt instruction). */
export const REMINDER_NOTE_MAX_CHARS = 280;
export const REMINDER_METRIC_MAX_CHARS = 60;
/** Hard cap on non-terminal reminders per user (active + proposed + due/surfaced). */
export const MAX_REMINDERS_PER_USER = 50;
/** How many reminders the recall sub-block carries into the snapshot. */
export const REMINDERS_INJECT_TOP_N = 6;
/** Bounds on the relative-`+Nd` grammar (1 day .. 1 year out). */
const MIN_RELATIVE_DAYS = 1;
const MAX_RELATIVE_DAYS = 366;
const OPEN_SENTINEL = "---REMEMBER---";
const CLOSE_SENTINEL = "---END---";
/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */
const SENTINEL_BYTE_CAP = 1024;
/** A loose metric key the model may name ("SLEEP", "BLOOD_PRESSURE"). */
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const RELATIVE_RE = /^\+(\d{1,3})([dw])$/i;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** The resolved trigger a valid `when` grammar maps onto. */
export interface ResolvedTrigger {
triggerKind: CoachReminderTriggerKind;
dueAt: Date | null;
contextCue: CoachReminderContextCue | null;
}
function isContextCue(value: string): value is CoachReminderContextCue {
return (COACH_REMINDER_CONTEXT_CUES as readonly string[]).includes(value);
}
/**
* Resolve the closed `when` grammar into a trigger, or `null` when the token is
* unparseable / out of bounds (the caller drops the reminder). Accepts:
* - an ISO calendar date `YYYY-MM-DD` → date trigger, dueAt 09:00 UTC that day
* - a relative `+Nd` / `+Nw` → date trigger, dueAt now + N days/weeks
* - a context-cue token from the enum → context trigger, dueAt null
*/
export function resolveWhenGrammar(
whenRaw: string,
now: Date,
): ResolvedTrigger | null {
const when = whenRaw.trim();
if (!when) return null;
if (ISO_DATE_RE.test(when)) {
// Anchor at 09:00 UTC on the named day — a deterministic, timezone-stable
// fire moment for the daily sweep (push delivery + per-tz receptivity land
// in a later wave; this is in-app surfacing only).
const dueAt = new Date(`${when}T09:00:00.000Z`);
if (Number.isNaN(dueAt.getTime())) return null;
return { triggerKind: "date", dueAt, contextCue: null };
}
const rel = RELATIVE_RE.exec(when);
if (rel) {
const n = Number.parseInt(rel[1], 10);
const unit = rel[2].toLowerCase();
const days = unit === "w" ? n * 7 : n;
if (days < MIN_RELATIVE_DAYS || days > MAX_RELATIVE_DAYS) return null;
return {
triggerKind: "date",
dueAt: new Date(now.getTime() + days * MS_PER_DAY),
contextCue: null,
};
}
const cue = when.toUpperCase();
if (isContextCue(cue)) {
return { triggerKind: "context", dueAt: null, contextCue: cue };
}
return null;
}
/** The fields a valid `---REMEMBER---` block yields. */
export interface ParsedReminderSentinel {
note: string;
/** Resolved trigger when a valid `when` was given; null = a recall-only note. */
trigger: ResolvedTrigger | null;
metric: string | null;
}
export interface RememberParseResult {
/** The reply with the block stripped (run AFTER the other sentinels). */
prose: string;
reminder: ParsedReminderSentinel | null;
/** True when an opening sentinel was seen but yielded no valid reminder. */
malformed: boolean;
}
/** Pull one `key: value` line out of the block body (first match wins). */
function fieldLine(body: string, key: string): string | null {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const colon = line.indexOf(":");
if (colon < 1) continue;
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
const value = line
.slice(colon + 1)
.trim()
// Strip a trailing inline comment + surrounding quotes/backticks.
.replace(/\s+#.*$/, "")
.replace(/^["'`]|["'`]$/g, "")
.trim();
if (value) return value;
}
return null;
}
/**
* Strip the `---REMEMBER---` block out of a reply and resolve its fields. Run
* AFTER the KEYVALUES + SUGGEST-REMINDER strips so the prose it receives is
* already free of those blocks. A missing `note`, or a present-but-unparseable
* `when`, drops the reminder (`malformed: true`) while still removing the raw
* marker from the prose.
*/
export function parseRememberSentinel(
raw: string,
now: Date,
): RememberParseResult {
if (!raw) return { prose: "", reminder: null, malformed: false };
const openIdx = raw.indexOf(OPEN_SENTINEL);
if (openIdx === -1) return { prose: raw, reminder: null, malformed: false };
const before = raw.slice(0, openIdx);
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
const body =
bodyRaw.length > SENTINEL_BYTE_CAP
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
: bodyRaw;
const after =
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
const noteRaw = fieldLine(body, "note");
if (!noteRaw) return { prose, reminder: null, malformed: true };
const note = noteRaw.slice(0, REMINDER_NOTE_MAX_CHARS).trim();
if (!note) return { prose, reminder: null, malformed: true };
// `when` is optional. Present-but-invalid is a malformed drop; absent is a
// recall-only note (active, no due moment).
const whenRaw = fieldLine(body, "when");
let trigger: ResolvedTrigger | null = null;
if (whenRaw) {
trigger = resolveWhenGrammar(whenRaw, now);
if (!trigger) return { prose, reminder: null, malformed: true };
}
let metric: string | null = null;
const metricRaw = fieldLine(body, "metric");
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
metric = metricRaw.slice(0, REMINDER_METRIC_MAX_CHARS).toUpperCase();
}
return { prose, reminder: { note, trigger, metric }, malformed: false };
}
/**
* The one system-prompt clause that teaches the model to emit `---REMEMBER---`.
* Appended to the assembled Coach system prompt in the chat route (the canonical
* system-prompt module is owned elsewhere). Provider-neutral; EN + DE only (the
* Coach reasons in de/en).
*/
export function buildRememberAddendum(locale: string | undefined): string {
if (locale?.toLowerCase().startsWith("de")) {
return `WENN die Person dich bittet, dir etwas zu merken oder sie später daran zu erinnern, hänge GENAU EINEN Block ans Ende deiner Antwort:
---REMEMBER---
note: <kurz, in der Formulierung der Person, worum es geht>
when: <optional: ISO-Datum JJJJ-MM-TT | relativ +Nd / +Nw | ein Kontext-Cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
metric: <optional, z. B. SLEEP, BLOOD_PRESSURE>
---END---
Erfinde NIEMALS eine Erinnerung, um die die Person nicht ausdrücklich gebeten hat. Bestätige kurz im Fließtext ("Notiert — ich komme darauf zurück."). Lass "when" weg, wenn keine Zeit genannt wurde.`;
}
return `WHEN the user asks you to remember something or remind them later, append EXACTLY ONE block at the end of your reply:
---REMEMBER---
note: <short, in the user's own framing, what to bring back>
when: <optional: ISO date YYYY-MM-DD | relative +Nd / +Nw | a context cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
metric: <optional, e.g. SLEEP, BLOOD_PRESSURE>
---END---
NEVER invent a reminder the user did not explicitly ask for. Acknowledge briefly in prose ("Got it — I'll bring that up again."). Omit "when" if no time was stated.`;
}
type PrismaLike = Pick<typeof prisma, "coachReminder">;
/**
* Persist a sentinel-captured reminder field-by-field (no mass assignment),
* always `status: "active"` (the user explicitly asked — no confirm gate). The
* per-user cap refuses a write once the non-terminal set is full. Best-effort:
* a failure never breaks the chat turn (the caller fires it fire-and-forget).
*
* Returns the created id, or `null` when the cap displaced it / a write failed.
*/
export async function captureReminderFromSentinel(args: {
userId: string;
conversationId: string;
parsed: ParsedReminderSentinel;
now?: Date;
db?: PrismaLike;
}): Promise<string | null> {
const db = args.db ?? prisma;
const { userId, conversationId, parsed } = args;
const nonTerminal = await db.coachReminder.count({
where: {
userId,
deletedAt: null,
status: { in: ["proposed", "active", "due", "surfaced"] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
annotate({
action: { name: "coach.reminder.capture_capped" },
meta: { conversationId },
});
return null;
}
const created = await db.coachReminder.create({
data: {
userId,
noteEncrypted: encryptToBytes(parsed.note),
metric: parsed.metric,
triggerKind: parsed.trigger?.triggerKind ?? "date",
dueAt: parsed.trigger?.dueAt ?? null,
contextCue: parsed.trigger?.contextCue ?? null,
status: "active",
source: "sentinel",
sourceConversationId: conversationId,
},
select: { id: true },
});
annotate({
action: { name: "coach.reminder.captured" },
meta: {
conversationId,
triggerKind: parsed.trigger?.triggerKind ?? "none",
hasDue: parsed.trigger?.dueAt != null,
},
});
return created.id;
}
+10
View File
@@ -2558,11 +2558,21 @@ function degradeToBudget(
block && Array.isArray(block.plans) && block.plans.length > 0
? block.plans
: null;
// v1.22 (B2/B3) — the episodic reminders survive the drop alongside
// facts/plans: they are the user's own "remember this" asks, tiny by
// construction (top-6, ≤280 chars each), and shedding them would make the
// Coach forget a reminder on exactly the data-heavy accounts that hit the
// char cap.
const reminders =
block && Array.isArray(block.reminders) && block.reminders.length > 0
? block.reminders
: null;
const survivors: Record<string, unknown> = {
omitted: "trimmed for prompt budget",
};
if (facts) survivors.facts = facts;
if (plans) survivors.plans = plans;
if (reminders) survivors.reminders = reminders;
snapshot[key] = survivors;
return true;
};
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.22 (F6) — generalised inline "confirm → apply" Coach action cards.
*
* The propose→confirm moat already ships for ONE action type — the cadence
* reminder (`suggest-reminder.ts` CADENCE_CATALOG → `suggestion` SSE frame →
* `reminder-suggestion-card.tsx` → `POST /api/coach/reminder-suggestions`).
* This module generalises it: the Coach may append a `---SUGGEST-ACTION---`
* block naming ONE action type from a CLOSED allowlist plus bounded,
* server-resolvable params. The route strips it, validates it, and emits an
* additive `suggestedAction` SSE frame the client renders as a one-tap confirm
* card. Confirm POSTs to `POST /api/coach/suggested-actions`, which builds the
* entity FIELD-BY-FIELD from a server-resolved spec.
*
* HARD SAFETY (matches the design §3.6):
* - NEVER auto-apply. The frame is additive; the prose stands alone if
* ignored. Only the user's confirm tap creates anything.
* - CLOSED allowlist only. The model names an action type + bounded params;
* it can never mint an arbitrary entity or widen a schedule (the interval
* is a closed preset id resolved on the server, never a free rrule).
* - NO clinical / medication changes via this path. The allowlist excludes
* anything that creates/edits a medication, a dose, or a clinical target.
*
* Defence-in-depth mirrors the other sentinels: a byte cap, a single block, the
* fields validated, a malformed block dropped (the user never sees the marker).
*/
/**
* The system-prompt clause teaching the model to emit `---SUGGEST-ACTION---`.
* Appended to the assembled Coach system prompt in the chat route. Provider-
* neutral; EN + DE only. Names ONLY the closed allowlist — never medication or
* clinical changes.
*/
export function buildSuggestActionAddendum(locale: string | undefined): string {
if (locale?.toLowerCase().startsWith("de")) {
return `WENN eine deiner Empfehlungen mit einem Tipp umsetzbar ist, biete sie als bestätigbare Aktion an — hänge GENAU EINEN Block ans Ende deiner Antwort. NUR diese geschlossene Liste, NIEMALS Medikamente oder klinische Ziele:
---SUGGEST-ACTION---
action: checkup.create # eine Vorsorge-/Check-up-Erinnerung
label: <kurzer Titel, z. B. Jährliches Blutbild>
interval: <yearly | halfYearly | quarterly | monthly>
---END---
oder
---SUGGEST-ACTION---
action: reminder.note # eine Erinnerung, auf etwas zurückzukommen
note: <kurz, worum es geht>
when: <optional: ISO-Datum | +Nd / +Nw | NEXT_BP_LOGGED …>
metric: <optional>
---END---
Wende NIEMALS etwas selbst an — die Person bestätigt mit einem Tipp. Schlage höchstens eine Aktion pro Antwort vor und nur, wenn sie klar hilfreich ist.`;
}
return `WHEN one of your recommendations is actionable with a reminder, offer it as a confirmable action — append EXACTLY ONE block at the end of your reply. ONLY this closed allowlist, NEVER a medication or clinical target:
---SUGGEST-ACTION---
action: checkup.create # a preventive-care / check-up reminder
label: <short title, e.g. Annual blood panel>
interval: <yearly | halfYearly | quarterly | monthly>
---END---
or
---SUGGEST-ACTION---
action: reminder.note # a reminder to revisit something
note: <short, what to bring back>
when: <optional: ISO date | +Nd / +Nw | NEXT_BP_LOGGED …>
metric: <optional>
---END---
NEVER apply anything yourself — the user confirms with a tap. Suggest at most one action per reply, and only when it is clearly helpful.`;
}
const OPEN_SENTINEL = "---SUGGEST-ACTION---";
const CLOSE_SENTINEL = "---END---";
const SENTINEL_BYTE_CAP = 1024;
/** The CLOSED action-type allowlist. NEVER add a clinical / medication type. */
export const SUGGESTED_ACTION_TYPES = [
"checkup.create",
"reminder.note",
] as const;
export type SuggestedActionType = (typeof SUGGESTED_ACTION_TYPES)[number];
export function isSuggestedActionType(v: string): v is SuggestedActionType {
return (SUGGESTED_ACTION_TYPES as readonly string[]).includes(v);
}
/** Field caps shared with the route Zod gate. */
export const CHECKUP_LABEL_MAX_CHARS = 120;
export const ACTION_NOTE_MAX_CHARS = 280;
export const ACTION_METRIC_MAX_CHARS = 60;
/**
* Closed cadence catalog for `checkup.create`. The model names ONLY a preset
* id; the server owns the RRULE so the model cannot mint an arbitrary schedule.
* A preventive-care item is, by nature, infrequent — the set is deliberately
* coarse (monthly at the tightest).
*/
export const CHECKUP_INTERVAL_CATALOG: Readonly<
Record<string, { id: string; rrule: string; labelKey: string }>
> = Object.freeze({
yearly: {
id: "yearly",
rrule: "FREQ=YEARLY;INTERVAL=1",
labelKey: "coach.suggestedAction.interval.yearly",
},
halfYearly: {
id: "halfYearly",
rrule: "FREQ=MONTHLY;INTERVAL=6",
labelKey: "coach.suggestedAction.interval.halfYearly",
},
quarterly: {
id: "quarterly",
rrule: "FREQ=MONTHLY;INTERVAL=3",
labelKey: "coach.suggestedAction.interval.quarterly",
},
monthly: {
id: "monthly",
rrule: "FREQ=MONTHLY;INTERVAL=1",
labelKey: "coach.suggestedAction.interval.monthly",
},
});
export type CheckupIntervalId = keyof typeof CHECKUP_INTERVAL_CATALOG;
export function isCheckupIntervalId(v: string): v is CheckupIntervalId {
return Object.prototype.hasOwnProperty.call(CHECKUP_INTERVAL_CATALOG, v);
}
/**
* The validated params the SSE frame carries + the confirm route accepts. A
* discriminated union keyed on `actionType`, so the route switch is exhaustive.
*/
export type SuggestedActionParams =
| { actionType: "checkup.create"; label: string; interval: CheckupIntervalId }
| {
actionType: "reminder.note";
note: string;
when?: string;
metric?: string;
};
/** The DTO the `suggestedAction` SSE frame carries (additive; older clients drop it). */
export interface CoachSuggestedAction {
actionType: SuggestedActionType;
/** Plain-text preview of what will be created (the model's own content). */
summary: string;
/** i18n key for the action-type heading on the card. */
titleKey: string;
params: SuggestedActionParams;
}
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
export interface SuggestActionParseResult {
prose: string;
action: CoachSuggestedAction | null;
malformed: boolean;
}
/** Pull one `key: value` line out of the block body (first match wins). */
function fieldLine(body: string, key: string): string | null {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const colon = line.indexOf(":");
if (colon < 1) continue;
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
const value = line
.slice(colon + 1)
.trim()
.replace(/\s+#.*$/, "")
.replace(/^["'`]|["'`]$/g, "")
.trim();
if (value) return value;
}
return null;
}
/** Resolve the per-action params from the block body; null = drop (malformed). */
function resolveActionParams(
actionType: SuggestedActionType,
body: string,
): { params: SuggestedActionParams; summary: string; titleKey: string } | null {
if (actionType === "checkup.create") {
const labelRaw = fieldLine(body, "label");
const intervalRaw = fieldLine(body, "interval");
if (!labelRaw || !intervalRaw) return null;
const label = labelRaw.slice(0, CHECKUP_LABEL_MAX_CHARS).trim();
if (!label) return null;
if (!isCheckupIntervalId(intervalRaw)) return null;
return {
params: { actionType, label, interval: intervalRaw },
summary: label,
titleKey: "coach.suggestedAction.checkup.title",
};
}
// reminder.note
const noteRaw = fieldLine(body, "note");
if (!noteRaw) return null;
const note = noteRaw.slice(0, ACTION_NOTE_MAX_CHARS).trim();
if (!note) return null;
const params: SuggestedActionParams = { actionType: "reminder.note", note };
const whenRaw = fieldLine(body, "when");
if (whenRaw) params.when = whenRaw.slice(0, 32);
const metricRaw = fieldLine(body, "metric");
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
params.metric = metricRaw.slice(0, ACTION_METRIC_MAX_CHARS).toUpperCase();
}
return {
params,
summary: note,
titleKey: "coach.suggestedAction.note.title",
};
}
/**
* Strip the `---SUGGEST-ACTION---` block and resolve it into a closed action.
* Run AFTER the other sentinel strips. An unknown action type, or a missing /
* invalid param, drops the action (`malformed: true`) while still removing the
* raw marker from the prose.
*/
export function parseSuggestAction(raw: string): SuggestActionParseResult {
if (!raw) return { prose: "", action: null, malformed: false };
const openIdx = raw.indexOf(OPEN_SENTINEL);
if (openIdx === -1) return { prose: raw, action: null, malformed: false };
const before = raw.slice(0, openIdx);
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
const body =
bodyRaw.length > SENTINEL_BYTE_CAP
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
: bodyRaw;
const after =
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
const actionRaw = fieldLine(body, "action");
if (!actionRaw || !isSuggestedActionType(actionRaw)) {
return { prose, action: null, malformed: true };
}
const resolved = resolveActionParams(actionRaw, body);
if (!resolved) return { prose, action: null, malformed: true };
return {
prose,
action: {
actionType: actionRaw,
summary: resolved.summary,
titleKey: resolved.titleKey,
params: resolved.params,
},
malformed: false,
};
}
+10
View File
@@ -8,6 +8,8 @@
*/
import { z } from "zod/v4";
import type { CoachSuggestedAction } from "./suggest-action";
/**
* Chat-message role. Stored as a free-form string column server-side
* (`coach_messages.role`) but constrained at the application layer so a
@@ -205,6 +207,7 @@ export type CoachStreamEvent =
metricSource: CoachProvenance;
}
| { type: "suggestion"; suggestion: CoachSuggestion }
| { type: "suggestedAction"; suggestedAction: CoachSuggestedAction }
| { type: "reasoning"; text: string }
| {
type: "done";
@@ -338,6 +341,13 @@ export interface CoachProvenance {
* on a conversation reload. Absent on turns that carry no suggestion.
*/
suggestion?: CoachSuggestion;
/**
* v1.22 (F6) — a generalised confirm→apply action card attached to this turn
* (`checkup.create` / `reminder.note`, closed allowlist). Persisted alongside
* the message so the card re-renders on a conversation reload. Absent on turns
* that carry no action.
*/
suggestedAction?: CoachSuggestedAction;
/**
* v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the
* Coach called and whether each found data (`present`). Metadata only — no
+133
View File
@@ -0,0 +1,133 @@
/**
* v1.22 (B2/M4) — daily Coach-reminder sweep.
*
* The active scheduler the "remind me" complaint needs: a single indexed query
* per tick that brings a reminder back at its chosen moment. Two sources:
*
* 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt`
* has passed → flipped to `due` (the in-app tile + indicator read `due` /
* `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped.
* 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read
* nowhere until now — see the design §1c) has passed and that are still
* `active` → a one-off `CoachReminder` is minted from the plan's own
* cue→action text (relatedPlanId set, source `extractor`) and the plan's
* `reviewDate` is cleared so the review fires once. This finally activates
* the column the B1 schema anticipated.
*
* In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4)
* land later. The sweep never calls a provider and never pushes — it only flips
* status + mints the plan-review reminder, both idempotent across re-fires.
*
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
* is a follow-on (F4); the column exists and capture supports it, but the date
* sweep is the high-value slice.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
// proactive passes run on settled overnight rows.
export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */
const PLAN_REVIEW_BATCH = 200;
export interface CoachReminderSweepSummary {
remindersDue: number;
planReviewsMinted: number;
errored: number;
}
type SweepPrisma = Pick<PrismaClient, "coachReminder" | "coachPlan">;
/**
* Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped,
* and a plan whose `reviewDate` was cleared on a prior tick is not re-minted.
*/
export async function runCoachReminderSweep(
prisma: SweepPrisma,
now: Date = new Date(),
): Promise<CoachReminderSweepSummary> {
const summary: CoachReminderSweepSummary = {
remindersDue: 0,
planReviewsMinted: 0,
errored: 0,
};
// ── 1. flip overdue active date-reminders → due ──────────────
const due = await prisma.coachReminder.updateMany({
where: {
deletedAt: null,
status: "active",
triggerKind: "date",
dueAt: { not: null, lte: now },
},
data: {
status: "due",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
summary.remindersDue = due.count;
// ── 2. mint plan-review reminders for passed reviewDates ──────
const plans = await prisma.coachPlan.findMany({
where: {
deletedAt: null,
status: "active",
reviewDate: { not: null, lte: now },
},
take: PLAN_REVIEW_BATCH,
select: {
id: true,
userId: true,
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
},
});
for (const plan of plans) {
try {
// The note is the plan's OWN cue→action prose (the user's words), never
// fabricated. Decrypt fault-isolated — an undecryptable plan is skipped
// (its reviewDate stays so a future key fix can still surface it).
let note: string;
try {
const ifCue = decryptFromBytes(plan.ifCueEncrypted);
const thenAction = decryptFromBytes(plan.thenActionEncrypted);
note = `${ifCue} → ${thenAction}`.slice(0, 280);
} catch {
summary.errored += 1;
continue;
}
await prisma.coachReminder.create({
data: {
userId: plan.userId,
noteEncrypted: encryptToBytes(note),
metric: plan.metric,
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: now,
status: "due",
source: "extractor",
lastSurfacedAt: now,
surfaceCount: 1,
},
});
// Clear the plan's reviewDate so the review fires exactly once.
await prisma.coachPlan.update({
where: { id: plan.id },
data: { reviewDate: null },
});
summary.planReviewsMinted += 1;
} catch {
summary.errored += 1;
}
}
return summary;
}
+43
View File
@@ -34,6 +34,11 @@ import {
COACH_NUDGE_CRON,
runCoachNudgeTick,
} from "@/lib/jobs/coach-nudge";
import {
COACH_REMINDER_SWEEP_QUEUE,
COACH_REMINDER_SWEEP_CRON,
runCoachReminderSweep,
} from "@/lib/jobs/coach-reminder-sweep";
import {
MEDICATION_LOW_STOCK_QUEUE,
MEDICATION_LOW_STOCK_CRON,
@@ -159,6 +164,9 @@ const allQueues = [
// v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15
// schedule silently no-ops and no nudge ever fires.
COACH_NUDGE_QUEUE,
// v1.22 (M4) — daily Coach-reminder sweep. Without this entry the 05:20
// schedule silently no-ops and no "remind me" reminder ever surfaces.
COACH_REMINDER_SWEEP_QUEUE,
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
// schedule silently no-ops and no low-stock alert ever fires.
MEDICATION_LOW_STOCK_QUEUE,
@@ -204,6 +212,10 @@ const schedules: ScheduleEntry[] = [
// the 04:45–04:55 score crons so the recovery-score trigger reads
// settled rows. Deterministic triggers only — no AI call on this path.
[COACH_NUDGE_QUEUE, COACH_NUDGE_CRON],
// v1.22 (M4) — daily 05:20 Europe/Berlin Coach-reminder sweep, just after
// the nudge tick. Flips overdue reminders to `due` + mints plan-review
// reminders from passed CoachPlan.reviewDate. In-app surface only; no push.
[COACH_REMINDER_SWEEP_QUEUE, COACH_REMINDER_SWEEP_CRON],
// v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a
// supply alert is an errand prompt, so it fires at a time the user
// can act on it. Once daily; the per-medication stamp keeps it at
@@ -447,5 +459,36 @@ export async function registerStatusQueues(
},
);
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue
// reminder to `due` and minting plan-review reminders are both idempotent
// across re-fires (an already-due reminder is not re-flipped; a plan whose
// reviewDate was cleared is not re-minted). No provider call, no push.
await boss.work(
COACH_REMINDER_SWEEP_QUEUE,
{ localConcurrency: 1 },
async () => {
await withBackgroundEvent("job.coach_reminder_sweep", async (evt) => {
try {
const summary = await runCoachReminderSweep(
getWorkerPrisma(),
new Date(),
);
evt.setBackground({
task_name: "job.coach_reminder_sweep",
result: {
reminders_due: summary.remindersDue,
plan_reviews_minted: summary.planReviewsMinted,
errored: summary.errored,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
},
);
return allQueues;
}