import { z } from "zod"; import type { MemoryCategory, MemorySourceType } from "@adventureos/shared"; import { MEMORY_CATEGORIES, SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared"; import { parseAiJson } from "@/lib/ai/parse-json"; import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config"; import { createSuggestion, getMemoryLearningSettings } from "./ai-memory"; const candidateSchema = z.object({ candidates: z .array( z.object({ category: z.string(), title: z.string(), content: z.string(), confidence: z.coerce.number(), sensitivity: z.enum(["normal", "private", "sensitive"]).optional(), }) ) .max(2), }); export type MemorySuggestionCandidate = { category: MemoryCategory; title: string; content: string; confidence: number; }; type PatternRule = { category: MemoryCategory; confidence: number; patterns: RegExp[]; titlePrefix?: string; content: (value: string) => string; }; const FILLER_PREFIX = /^(?:that\s+|to\s+|i\s+|i'm\s+|i am\s+|my\s+)/i; const RULES: PatternRule[] = [ { category: "current_goals", confidence: 0.9, patterns: [ /^(?:one of my goals|my current goal|my goal|a goal of mine)(?:\s+for\s+.+?)?\s+is\s+(.+)$/i, /^i want to\s+(.+)$/i, /^i'm trying to\s+(.+)$/i, /^i am trying to\s+(.+)$/i, /^i want to be more\s+(.+)$/i, /^my current focus is\s+(.+)$/i, ], content: (value) => `One of the user's current goals is to ${ensureVerbPhrase(value)}.`, }, { category: "likes", confidence: 0.85, patterns: [/^i like\s+(.+)$/i], content: (value) => `The user likes ${lowerFirst(value)}.`, }, { category: "dislikes", confidence: 0.85, patterns: [/^i dislike\s+(.+)$/i, /^i don't like\s+(.+)$/i, /^i do not like\s+(.+)$/i], content: (value) => `The user dislikes ${lowerFirst(value)}.`, }, { category: "ai_tone", confidence: 0.9, patterns: [/^i prefer\s+(short explanations|direct explanations|concise explanations|brief explanations|.+\s+explanations)$/i, /^i want the ai to\s+(.+)$/i], content: (value) => /\bexplanations\b/i.test(value) ? `The user prefers ${lowerFirst(value)}.` : `The user wants the AI to ${ensureVerbPhrase(value)}.`, }, { category: "worries", confidence: 0.8, patterns: [/^i get worried when\s+(.+)$/i, /^i'm worried about\s+(.+)$/i, /^i am worried about\s+(.+)$/i], content: (value) => `The user has a worry or concern about ${lowerFirst(value)}.`, }, { category: "personal_context", confidence: 0.75, patterns: [/^i struggle with\s+(.+)$/i, /^i need help with\s+(.+)$/i], content: (value) => `The user needs support with ${lowerFirst(value)}.`, }, { category: "personal_context", confidence: 0.95, patterns: [/^(?:remember that|save this:?|add this to memory:?)(.+)$/i], titlePrefix: "Remember", content: (value) => `The user explicitly asked the app to remember that ${stripTrailingPunctuation(value)}.`, }, ]; const LEARNING_HINTS = /\b(learn|learning|examples?|explanations?|teach|lesson)\b/i; const EXERCISE_HINTS = /\b(exercise|work out|workout|run|gym|walk|train)\b/i; const UNSPECIFIC_MESSAGES = /^(hello|hi|hey|thanks|thank you|what can you do\??|that's interesting|that is interesting)$/i; export function extractDeterministicMemoryCandidates(sourceText: string): MemorySuggestionCandidate[] { const normalized = normalizeInput(sourceText); if (!normalized || normalized.length < 8 || UNSPECIFIC_MESSAGES.test(normalized)) return []; for (const rule of RULES) { for (const pattern of rule.patterns) { const match = normalized.match(pattern); const rawValue = match?.[1]?.trim(); if (!rawValue) continue; const value = cleanValue(rawValue); if (!isDurableEnough(value)) return []; const category = refineCategory(rule.category, normalized, value); return [ { category, title: buildTitle(value, rule.titlePrefix), content: refineContent(rule.content(value), normalized), confidence: rule.confidence, }, ]; } } return []; } export async function extractMemoryCandidates( userId: string, sourceType: MemorySourceType, sourceText: string, sourceRef?: { type: string; id?: string; date?: string } ) { const settings = await getMemoryLearningSettings(userId); if ( !settings.learningEnabled || !settings.autoSuggestEnabled || (sourceType === "chat" && !settings.suggestAfterChat) ) { return []; } const results = []; const deterministic = extractDeterministicMemoryCandidates(sourceText); for (const c of deterministic) { if (!settings.allowedCategories.includes(c.category)) continue; if ( SENSITIVE_MEMORY_CATEGORIES.includes(c.category) && !settings.allowSensitiveCategories ) { continue; } const row = await createSuggestion(userId, { category: c.category, title: c.title, content: c.content, sourceType, sourceRef, confidence: c.confidence, }); if (row) results.push(row); } let behavior: Awaited>; let providerConfig: Awaited>; try { behavior = await getAiBehaviorConfig(userId); providerConfig = await getAiProviderConfig(userId); } catch { return results; } if (!behavior.enabled || !providerConfig.enabled) return results; const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses). User text: """ ${sourceText.slice(0, 1500)} """ Use only these categories: ${Object.keys(MEMORY_CATEGORIES).join(", ")}. Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact about the user", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] } If nothing clear, return { "candidates": [] }.`; try { const coreSystem = await getTemplateBody(userId); const res = await generateTextWithFallback( userId, { model: providerConfig.model, prompt, system: buildSystemPrompt(behavior, coreSystem), format: "json", temperature: 0.3, maxTokens: 400, timeoutMs: providerConfig.timeoutMs, }, behavior ); const parsed = candidateSchema.safeParse(parseAiJson(res.text)); if (!parsed.success) return results; for (const c of parsed.data.candidates) { if (c.confidence < 0.6) continue; if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue; if ( SENSITIVE_MEMORY_CATEGORIES.includes(c.category as MemoryCategory) && !settings.allowSensitiveCategories ) { continue; } const row = await createSuggestion(userId, { category: c.category as MemoryCategory, title: c.title, content: c.content, sourceType, sourceRef, confidence: c.confidence, }); if (row) results.push(row); } return results; } catch { return results; } } async function getTemplateBody(userId: string) { const { getTemplateBody: getTpl } = await import("./ai-templates"); return getTpl(userId, "system_core"); } function normalizeInput(value: string): string { return value.trim().replace(/[’‘]/g, "'").replace(/\s+/g, " "); } function cleanValue(value: string): string { return stripTrailingPunctuation(value.replace(FILLER_PREFIX, "").trim()); } function stripTrailingPunctuation(value: string): string { return value.trim().replace(/[.!?]+$/g, "").trim(); } function isDurableEnough(value: string): boolean { const lower = value.toLowerCase(); if (value.length < 4) return false; if (/^(this|that|it|stuff|things|more|better)$/i.test(lower)) return false; return true; } function refineCategory(category: MemoryCategory, fullText: string, value: string): MemoryCategory { const combined = `${fullText} ${value}`; if (category === "likes" && LEARNING_HINTS.test(combined)) return "learning_interests"; if (category === "current_goals" && EXERCISE_HINTS.test(combined)) return "exercise_preferences"; return category; } function refineContent(content: string, fullText: string): string { if (/one of my goals/i.test(fullText) && /next coming weeks|next few weeks/i.test(fullText)) { return content.replace("current goals is to", "goals for the next few weeks is to"); } return content; } function ensureVerbPhrase(value: string): string { const cleaned = lowerFirst(stripTrailingPunctuation(value)); if (/^(be|build|wake|read|exercise|work|study|learn|sleep|get|start|stop|finish|practice|focus)\b/i.test(cleaned)) { return cleaned; } return cleaned; } function lowerFirst(value: string): string { return value.charAt(0).toLowerCase() + value.slice(1); } function buildTitle(value: string, prefix?: string): string { const clean = stripTrailingPunctuation(value) .replace(/\b7\s*a\.?\s*m\.?\b/gi, "7am") .replace(/\s+/g, " ") .trim(); const title = clean .replace(/^(?:to\s+)/i, "") .split(" ") .slice(0, 8) .map((word, index) => { if (/^\d/.test(word)) return word; if (index > 0 && /^(at|to|with|for|and|or|the|a|an|of)$/i.test(word)) { return word.toLowerCase(); } return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .join(" "); return prefix ? `${prefix}: ${title}` : title; }