import { and, eq, desc, isNull } from "drizzle-orm"; import { db, aiChatSessions, aiChatMessages } from "../db"; import { buildMentorContext, formatContextForPrompt } from "./ai-context"; import { generateChatReply, getAiAvailability } from "./ai"; import { formatLibraryContextForPrompt, getLibraryContextForTopic, shouldIncludeLibraryContextInChat, } from "./library-context"; import { listMemories, getProfileSummary } from "./ai-memory"; import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared"; import { extractMemoryCandidates } from "./memory-extraction"; import { buildMemoryActionReply, performChatMemoryAction } from "./chat-memory-actions"; export async function listChatSessions(userId: string) { return db .select() .from(aiChatSessions) .where(and(eq(aiChatSessions.userId, userId), isNull(aiChatSessions.archivedAt))) .orderBy(desc(aiChatSessions.updatedAt)) .limit(20); } export async function createChatSession( userId: string, title?: string, featureContext?: Record ) { const [session] = await db .insert(aiChatSessions) .values({ userId, title: title ?? "New conversation", featureContext: featureContext ?? {}, }) .returning(); return session; } export async function getChatSession(userId: string, sessionId: string) { const [session] = await db .select() .from(aiChatSessions) .where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId))); if (!session) return null; const messages = await db .select() .from(aiChatMessages) .where(eq(aiChatMessages.sessionId, sessionId)) .orderBy(aiChatMessages.createdAt); return { session, messages }; } export async function sendChatMessage(userId: string, sessionId: string, content: string) { const sessionData = await getChatSession(userId, sessionId); if (!sessionData) throw new Error("Session not found"); const { session, messages } = sessionData; const [userMsg] = await db .insert(aiChatMessages) .values({ sessionId, role: "user", content: content.trim(), }) .returning(); const memoryAction = await performChatMemoryAction(userId, sessionId, userMsg, messages); const authoritativeMemoryReply = buildMemoryActionReply(memoryAction); const availability = await getAiAvailability(userId); const ctx = await buildMentorContext(userId, { userMessage: content, feature: "mentor", activeDate: (session.featureContext as Record)?.date as string | undefined, logContext: true, }); let contextBlock = formatContextForPrompt(ctx); if (shouldIncludeLibraryContextInChat(content)) { try { const libraryPayload = await getLibraryContextForTopic(userId, content); contextBlock = `${contextBlock}\n\n${formatLibraryContextForPrompt(libraryPayload)}`; } catch { /* library optional */ } } let reply: string; let offline = false; const memoryIdsUsed = ctx.memoryIds; if (authoritativeMemoryReply) { reply = authoritativeMemoryReply; } else if (!availability.canUse || !availability.online) { offline = true; reply = buildOfflineReply(content, ctx); } else { const history = messages .slice(-6) .map((m) => `${m.role}: ${m.content}`) .join("\n"); const generated = await generateChatReply(userId, { userMessage: content, contextBlock, history, memoryAction: JSON.stringify(memoryAction), }); reply = generated ?? "I'm having trouble responding right now. Your memories are still saved — try again when the AI is online."; if (!generated) offline = true; } const [assistantMsg] = await db .insert(aiChatMessages) .values({ sessionId, role: "assistant", content: reply, metadata: { memoryIdsUsed, offline, memoryAction }, }) .returning(); const memorySuggestions = memoryAction.intent === "ordinary_message" ? await extractMemoryCandidates(userId, "chat", content, { type: "ai_chat", id: userMsg.id, }) : memoryAction.type === "memory_suggestion_created" ? [ { id: memoryAction.id, title: memoryAction.title, category: memoryAction.category, status: memoryAction.status, }, ] : []; await db .update(aiChatSessions) .set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title }) .where(eq(aiChatSessions.id, sessionId)); return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions, memoryAction }; } function truncateTitle(text: string): string { const t = text.trim(); return t.length > 40 ? `${t.slice(0, 37)}…` : t; } function buildOfflineReply(message: string, ctx: Awaited>): string { const lower = message.toLowerCase(); if (lower.includes("what do you know") || lower.includes("about me")) { const memLines = ctx.memories.map((m) => `• ${m.title}: ${m.content}`).join("\n"); return [ "The AI is offline, but here is what I have saved locally:", ctx.profileSummary ? `\nSummary: ${ctx.profileSummary}` : "", memLines ? `\nMemories:\n${memLines}` : "\nNo memories saved yet.", "\nYou can edit these in Settings → AI Memory.", ].join(""); } return "The mentor is offline right now. Your adventure data is still here — try again when AI is online, or check Settings → AI Memory."; } export async function archiveChatSession(userId: string, sessionId: string) { await db .update(aiChatSessions) .set({ archivedAt: new Date() }) .where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId))); } export async function getWhatAiKnows(userId: string) { const summary = await getProfileSummary(userId); const memories = await listMemories(userId, { enabled: true }); const grouped: Record = {}; for (const m of memories) { if (!grouped[m.category]) grouped[m.category] = []; grouped[m.category].push(m); } return { summary: summary?.summary ?? "", grouped: Object.entries(grouped).map(([category, items]) => ({ category, label: MEMORY_CATEGORIES[category as MemoryCategory] ?? category, items, })), }; }