expanding context of ai
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-28 22:31:02 +01:00
parent a76ba7bbab
commit 701d39982f
4 changed files with 186 additions and 4 deletions

2
apps/web/public/sw.js Executable file → Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ describe("formatContextForPrompt", () => {
memoryIds: [], memoryIds: [],
activeDay: { dayMode: "low_energy", isBackfilled: false }, activeDay: { dayMode: "low_energy", isBackfilled: false },
recentActivity: "recent", recentActivity: "recent",
recentLogDetails: "",
featureSlice: "", featureSlice: "",
tokenEstimate: 100, tokenEstimate: 100,
layers: { memories: "" }, layers: { memories: "" },
@@ -24,10 +25,26 @@ describe("formatContextForPrompt", () => {
memoryIds: ["1"], memoryIds: ["1"],
activeDay: { isBackfilled: true, dayMode: "normal" }, activeDay: { isBackfilled: true, dayMode: "normal" },
recentActivity: "", recentActivity: "",
recentLogDetails: "",
featureSlice: "", featureSlice: "",
tokenEstimate: 50, tokenEstimate: 50,
layers: { memories: "[likes] T: C" }, layers: { memories: "[likes] T: C" },
}); });
expect(text).toContain("backfilled"); expect(text).toContain("backfilled");
}); });
it("includes recent log details", () => {
const text = formatContextForPrompt({
profileSummary: "",
memories: [],
memoryIds: [],
activeDay: { dayMode: "normal" },
recentActivity: "",
recentLogDetails: "2026-06-27: Items: Prayer: 3/5 checked (partial)",
featureSlice: "",
tokenEstimate: 50,
layers: { memories: "" },
});
expect(text).toContain("Prayer: 3/5");
});
}); });

View File

@@ -1,5 +1,5 @@
import { format, subDays } from "date-fns"; import { format, subDays } from "date-fns";
import type { MemoryCategory, DayMode } from "@adventureos/shared"; import type { MemoryCategory } from "@adventureos/shared";
import { MEMORY_CATEGORIES } from "@adventureos/shared"; import { MEMORY_CATEGORIES } from "@adventureos/shared";
import { requireUser } from "./user"; import { requireUser } from "./user";
import { getDailyAdventure, buildDaySnapshot } from "./adventure"; import { getDailyAdventure, buildDaySnapshot } from "./adventure";
@@ -7,7 +7,7 @@ import { getReflection } from "./reflection";
import { getProfileSummary, listMemories, markMemoriesUsed } from "./ai-memory"; import { getProfileSummary, listMemories, markMemoriesUsed } from "./ai-memory";
import { getLogicalToday } from "../dates"; import { getLogicalToday } from "../dates";
import { getDayBoundaryHour } from "./day-boundary"; import { getDayBoundaryHour } from "./day-boundary";
import { db, explorations, weeklyReviews, aiContextLogs } from "../db"; import { db, explorations, weeklyReviews, aiContextLogs, readingLogs, books } from "../db";
import { and, eq, desc } from "drizzle-orm"; import { and, eq, desc } from "drizzle-orm";
import { weekStartString } from "../dates"; import { weekStartString } from "../dates";
@@ -43,6 +43,7 @@ export type BuiltContext = {
memoryIds: string[]; memoryIds: string[];
activeDay: Record<string, unknown> | null; activeDay: Record<string, unknown> | null;
recentActivity: string; recentActivity: string;
recentLogDetails: string;
featureSlice: string; featureSlice: string;
tokenEstimate: number; tokenEstimate: number;
layers: Record<string, string>; layers: Record<string, string>;
@@ -81,6 +82,162 @@ function categoriesFromMessage(message: string): Set<string> {
return cats; return cats;
} }
function unique<T>(items: T[]): T[] {
return [...new Set(items)];
}
function contextDates(activeDate: string, message: string): string[] {
const lower = message.toLowerCase();
const dates = [activeDate];
for (let i = 1; i < 7; i++) {
dates.push(format(subDays(new Date(activeDate), i), "yyyy-MM-dd"));
}
if (lower.includes("today")) dates.unshift(activeDate);
if (lower.includes("yesterday")) {
dates.unshift(format(subDays(new Date(activeDate), 1), "yyyy-MM-dd"));
}
const explicitDates = message.match(/\b\d{4}-\d{2}-\d{2}\b/g) ?? [];
dates.unshift(...explicitDates);
return unique(dates).slice(0, 10);
}
function summarizeItemValue(item: {
type: string;
label: string;
state: string;
value: Record<string, unknown>;
}): string | null {
if (item.type === "checklist") {
const checks = (item.value?.checks as boolean[]) ?? [];
const checked = checks.filter(Boolean).length;
return `${checked}/${checks.length} checked (${item.state})`;
}
if (item.type === "duration") {
const hours = Number(item.value?.hours ?? 0);
return `${hours.toFixed(1)}h (${item.state})`;
}
if (item.type === "reading") {
const pages = Number(item.value?.pages ?? 0);
return pages > 0 ? `${pages} pages (${item.state})` : `0 pages (${item.state})`;
}
if (item.type === "note") {
const note = String(item.value?.note ?? "").trim();
return note ? truncate(note, 220) : null;
}
if (item.type === "checkbox" || item.type === "timeblock") {
return item.state === "done" ? "done" : "not done";
}
return Object.keys(item.value ?? {}).length > 0 ? JSON.stringify(item.value) : item.state;
}
function shouldIncludeItem(item: {
type: string;
state: string;
value: Record<string, unknown>;
}): boolean {
if (item.type === "checklist") return true;
if (item.type === "note") return Boolean(String(item.value?.note ?? "").trim());
return item.state !== "blank" || Object.keys(item.value ?? {}).length > 0;
}
export async function buildRecentLogDetails(
userId: string,
activeDate: string,
message = ""
): Promise<string> {
const lines: string[] = [];
for (const date of contextDates(activeDate, message)) {
const { adventure, items, todos } = await getDailyAdventure(userId, date);
const reflection = await getReflection(userId, date);
const dayLines: string[] = [];
const itemLines = items
.filter((i) => i.enabled && shouldIncludeItem({
type: i.type,
state: i.state,
value: i.value as Record<string, unknown>,
}))
.map((i) => {
const summary = summarizeItemValue({
type: i.type,
label: i.label,
state: i.state,
value: i.value as Record<string, unknown>,
});
return summary ? `${i.label}: ${summary}` : null;
})
.filter(Boolean);
if (itemLines.length > 0) dayLines.push(`Items: ${itemLines.join("; ")}`);
if (todos.length > 0) {
const done = todos.filter((t) => t.done).map((t) => t.label);
const open = todos.filter((t) => !t.done).map((t) => t.label);
dayLines.push(
`Todos: ${done.length}/${todos.length} done` +
(done.length ? `; done: ${done.join(", ")}` : "") +
(open.length ? `; open: ${open.join(", ")}` : "")
);
}
if (reflection) {
const reflectionParts = [
reflection.wentWell ? `went well: ${truncate(reflection.wentWell, 180)}` : "",
reflection.learned ? `learned: ${truncate(reflection.learned, 180)}` : "",
reflection.improveTomorrow
? `improve tomorrow: ${truncate(reflection.improveTomorrow, 180)}`
: "",
].filter(Boolean);
if (reflectionParts.length > 0) dayLines.push(`Reflection: ${reflectionParts.join("; ")}`);
}
const reading = await db
.select({
title: books.title,
author: books.author,
pagesRead: readingLogs.pagesRead,
note: readingLogs.note,
})
.from(readingLogs)
.innerJoin(books, eq(readingLogs.bookId, books.id))
.where(and(eq(books.userId, userId), eq(readingLogs.date, date)));
if (reading.length > 0) {
dayLines.push(
`Reading logs: ${reading
.map(
(r) =>
`${r.title}${r.author ? ` by ${r.author}` : ""}: ${r.pagesRead}p` +
(r.note ? `, note: ${truncate(r.note, 140)}` : "")
)
.join("; ")}`
);
}
if (dayLines.length > 0 || adventure.isRestDay || adventure.isBackfilled) {
const flags = [
adventure.dayMode && adventure.dayMode !== "normal" ? `mode=${adventure.dayMode}` : "",
adventure.isRestDay ? "rest day" : "",
adventure.isBackfilled ? "backfilled" : "",
adventure.loggedAt ? `logged=${adventure.loggedAt.toISOString()}` : "",
].filter(Boolean);
lines.push(`${date}${flags.length ? ` (${flags.join(", ")})` : ""}: ${dayLines.join(" | ")}`);
}
}
return truncate(lines.join("\n"), 3500);
}
export async function buildMentorContext( export async function buildMentorContext(
userId: string, userId: string,
opts: MentorContextOptions = {} opts: MentorContextOptions = {}
@@ -112,14 +269,17 @@ export async function buildMentorContext(
let activeDay: Record<string, unknown> | null = null; let activeDay: Record<string, unknown> | null = null;
try { try {
const { adventure, items } = await getDailyAdventure(userId, activeDate); const { adventure, items, todos } = await getDailyAdventure(userId, activeDate);
activeDay = { activeDay = {
date: activeDate, date: activeDate,
dayMode: adventure.dayMode ?? "normal", dayMode: adventure.dayMode ?? "normal",
isRestDay: adventure.isRestDay, isRestDay: adventure.isRestDay,
isBackfilled: adventure.isBackfilled, isBackfilled: adventure.isBackfilled,
loggedAt: adventure.loggedAt?.toISOString() ?? null,
itemsLogged: items.filter((i) => i.state !== "blank").length, itemsLogged: items.filter((i) => i.state !== "blank").length,
itemCount: items.filter((i) => i.enabled && i.type !== "note").length, itemCount: items.filter((i) => i.enabled && i.type !== "note").length,
todosDone: todos.filter((t) => t.done).length,
todoCount: todos.length,
}; };
} catch { } catch {
activeDay = { date: activeDate }; activeDay = { date: activeDate };
@@ -136,6 +296,7 @@ export async function buildMentorContext(
); );
} }
const recentActivity = truncate(digestParts.join("\n"), 800); const recentActivity = truncate(digestParts.join("\n"), 800);
const recentLogDetails = await buildRecentLogDetails(userId, activeDate, message);
let featureSlice = ""; let featureSlice = "";
if (opts.feature === "teacher" && opts.topic) { if (opts.feature === "teacher" && opts.topic) {
@@ -154,6 +315,7 @@ export async function buildMentorContext(
memories: memories.map((m) => `[${m.category}] ${m.title}: ${m.content}`).join("\n"), memories: memories.map((m) => `[${m.category}] ${m.title}: ${m.content}`).join("\n"),
activeDay: JSON.stringify(activeDay), activeDay: JSON.stringify(activeDay),
recentActivity, recentActivity,
recentLogDetails,
featureSlice, featureSlice,
}; };
@@ -179,6 +341,7 @@ export async function buildMentorContext(
memoryIds, memoryIds,
activeDay, activeDay,
recentActivity, recentActivity,
recentLogDetails,
featureSlice, featureSlice,
tokenEstimate, tokenEstimate,
layers, layers,
@@ -265,6 +428,7 @@ export function formatContextForPrompt(ctx: BuiltContext): string {
`Today: ${ctx.layers.activeDay}`, `Today: ${ctx.layers.activeDay}`,
dayNote, dayNote,
`Recent:\n${ctx.recentActivity}`, `Recent:\n${ctx.recentActivity}`,
ctx.recentLogDetails ? `Recent log details:\n${ctx.recentLogDetails}` : "",
ctx.featureSlice, ctx.featureSlice,
] ]
.filter(Boolean) .filter(Boolean)

View File

@@ -526,6 +526,7 @@ export async function generateChatReply(
`User: ${input.userMessage}`, `User: ${input.userMessage}`,
input.memoryAction ? `Application memoryAction result: ${input.memoryAction}` : "", input.memoryAction ? `Application memoryAction result: ${input.memoryAction}` : "",
"Memory guardrail: You must not claim that a memory has been saved, added, approved, rejected, deleted, or changed unless the application memoryAction result confirms it. If no memoryAction result confirms persistence, do not imply that memory persistence happened.", "Memory guardrail: You must not claim that a memory has been saved, added, approved, rejected, deleted, or changed unless the application memoryAction result confirms it. If no memoryAction result confirms persistence, do not imply that memory persistence happened.",
"Log access: If the user asks about notes, prayers, todos, reading, reflections, or other logged activity, answer from the Personal context records. If a requested date or log entry is not present in context, say that you do not have that record in the current context instead of guessing.",
"If the user asks whether you can learn about them, explain that AdventureOS can suggest memories from goals, preferences, routines, worries, and direct remember requests; the user can review, edit, accept, reject, or ignore suggestions before they become saved memory.", "If the user asks whether you can learn about them, explain that AdventureOS can suggest memories from goals, preferences, routines, worries, and direct remember requests; the user can review, edit, accept, reject, or ignore suggestions before they become saved memory.",
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.", "Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
] ]