import { getAIProvider } from '../factory' import { cosineSimilarity } from '@/lib/utils' import prisma from '@/lib/prisma' export interface NoteConnection { note1: { id: string title: string | null content: string createdAt: Date } note2: { id: string title: string | null content: string | null createdAt: Date } similarityScore: number insight: string daysApart: number } export interface MemoryEchoInsight { id: string note1Id: string note2Id: string note1: { id: string title: string | null content: string } note2: { id: string title: string | null content: string } similarityScore: number insight: string insightDate: Date viewed: boolean feedback: string | null } /** * Memory Echo Service - Proactive note connections * "I didn't search, it found me" */ export class MemoryEchoService { private readonly SIMILARITY_THRESHOLD = 0.75 // High threshold for quality connections private readonly SIMILARITY_THRESHOLD_DEMO = 0.50 // Lower threshold for demo mode private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam /** * Find meaningful connections between user's notes */ async findConnections(userId: string, demoMode: boolean = false): Promise { // Get all user's notes with embeddings const notes = await prisma.note.findMany({ where: { userId, isArchived: false, embedding: { not: null } // Only notes with embeddings }, select: { id: true, title: true, content: true, embedding: true, createdAt: true }, orderBy: { createdAt: 'desc' } }) if (notes.length < 2) { return [] // Need at least 2 notes to find connections } // Parse embeddings const notesWithEmbeddings = notes .map(note => ({ ...note, embedding: note.embedding ? JSON.parse(note.embedding) : null })) .filter(note => note.embedding && Array.isArray(note.embedding)) const connections: NoteConnection[] = [] // Use demo mode parameters if enabled const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD // Compare all pairs of notes for (let i = 0; i < notesWithEmbeddings.length; i++) { for (let j = i + 1; j < notesWithEmbeddings.length; j++) { const note1 = notesWithEmbeddings[i] const note2 = notesWithEmbeddings[j] // Calculate time difference const daysApart = Math.abs( Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24)) ) // Time diversity filter: notes must be from different time periods if (daysApart < minDaysApart) { continue } // Calculate cosine similarity const similarity = cosineSimilarity(note1.embedding, note2.embedding) // Similarity threshold for meaningful connections if (similarity >= similarityThreshold) { connections.push({ note1: { id: note1.id, title: note1.title, content: note1.content.substring(0, 200) + (note1.content.length > 200 ? '...' : ''), createdAt: note1.createdAt }, note2: { id: note2.id, title: note2.title, content: note2.content ? note2.content.substring(0, 200) + (note2.content.length > 200 ? '...' : '') : '', createdAt: note2.createdAt }, similarityScore: similarity, insight: '', // Will be generated by AI daysApart }) } } } // Sort by similarity score (descending) connections.sort((a, b) => b.similarityScore - a.similarityScore) // Return top connections return connections.slice(0, 10) } /** * Generate AI explanation for the connection */ async generateInsight( note1Title: string | null, note1Content: string, note2Title: string | null, note2Content: string ): Promise { try { const config = await prisma.systemConfig.findFirst() const provider = getAIProvider(config || undefined) const note1Desc = note1Title || 'Untitled note' const note2Desc = note2Title || 'Untitled note' const prompt = `You are a helpful assistant analyzing connections between notes. Note 1: "${note1Desc}" Content: ${note1Content.substring(0, 300)} Note 2: "${note2Desc}" Content: ${note2Content.substring(0, 300)} Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.` const response = await provider.generateText(prompt) // Clean up response const insight = response .replace(/^["']|["']$/g, '') // Remove quotes .replace(/^[^.]+\.\s*/, '') // Remove "Here is..." prefix .trim() .substring(0, 150) // Max length return insight || 'These notes appear to be semantically related.' } catch (error) { console.error('[MemoryEcho] Failed to generate insight:', error) return 'These notes appear to be semantically related.' } } /** * Get next pending insight for user (based on frequency limit) */ async getNextInsight(userId: string): Promise { // Check if Memory Echo is enabled for user const settings = await prisma.userAISettings.findUnique({ where: { userId } }) if (!settings || !settings.memoryEcho) { return null // Memory Echo disabled } const demoMode = settings.demoMode || false // Skip frequency checks in demo mode if (!demoMode) { // Check frequency limit const today = new Date() today.setHours(0, 0, 0, 0) const insightsShownToday = await prisma.memoryEchoInsight.count({ where: { userId, insightDate: { gte: today } } }) // Frequency limits const maxPerDay = settings.memoryEchoFrequency === 'daily' ? 1 : settings.memoryEchoFrequency === 'weekly' ? 0 : // 1 per 7 days (handled below) 3 // custom = 3 per day if (settings.memoryEchoFrequency === 'weekly') { // Check if shown in last 7 days const weekAgo = new Date(today) weekAgo.setDate(weekAgo.getDate() - 7) const recentInsight = await prisma.memoryEchoInsight.findFirst({ where: { userId, insightDate: { gte: weekAgo } } }) if (recentInsight) { return null // Already shown this week } } else if (insightsShownToday >= maxPerDay) { return null // Daily limit reached } // Check total insights limit (prevent spam) const totalInsights = await prisma.memoryEchoInsight.count({ where: { userId } }) if (totalInsights >= this.MAX_INSIGHTS_PER_USER) { return null // User has too many insights } } // Find new connections (pass demoMode) const connections = await this.findConnections(userId, demoMode) if (connections.length === 0) { return null // No connections found } // Filter out already shown connections const existingInsights = await prisma.memoryEchoInsight.findMany({ where: { userId }, select: { note1Id: true, note2Id: true } }) const shownPairs = new Set( existingInsights.map(i => `${i.note1Id}-${i.note2Id}`) ) const newConnection = connections.find(c => !shownPairs.has(`${c.note1.id}-${c.note2.id}`) && !shownPairs.has(`${c.note2.id}-${c.note1.id}`) ) if (!newConnection) { return null // All connections already shown } // Generate AI insight const insightText = await this.generateInsight( newConnection.note1.title, newConnection.note1.content, newConnection.note2.title, newConnection.note2.content || '' ) // Store insight in database const insight = await prisma.memoryEchoInsight.create({ data: { userId, note1Id: newConnection.note1.id, note2Id: newConnection.note2.id, similarityScore: newConnection.similarityScore, insight: insightText, insightDate: new Date(), viewed: false }, include: { note1: { select: { id: true, title: true, content: true } }, note2: { select: { id: true, title: true, content: true } } } }) return insight } /** * Mark insight as viewed */ async markAsViewed(insightId: string): Promise { await prisma.memoryEchoInsight.update({ where: { id: insightId }, data: { viewed: true } }) } /** * Submit feedback for insight */ async submitFeedback(insightId: string, feedback: 'thumbs_up' | 'thumbs_down'): Promise { await prisma.memoryEchoInsight.update({ where: { id: insightId }, data: { feedback } }) // Optional: Store in AiFeedback for analytics const insight = await prisma.memoryEchoInsight.findUnique({ where: { id: insightId }, select: { userId: true, note1Id: true } }) if (insight) { await prisma.aiFeedback.create({ data: { noteId: insight.note1Id, userId: insight.userId, feedbackType: feedback, feature: 'memory_echo', originalContent: JSON.stringify({ insightId }), metadata: JSON.stringify({ timestamp: new Date().toISOString() }) } }) } } /** * Get all connections for a specific note */ async getConnectionsForNote(noteId: string, userId: string): Promise { // Get the note with embedding const targetNote = await prisma.note.findUnique({ where: { id: noteId }, select: { id: true, title: true, content: true, embedding: true, createdAt: true, userId: true } }) if (!targetNote || targetNote.userId !== userId) { return [] // Note not found or doesn't belong to user } if (!targetNote.embedding) { return [] // Note has no embedding } // Get dismissed connections for this note (to filter them out) const dismissedInsights = await prisma.memoryEchoInsight.findMany({ where: { userId, dismissed: true, OR: [ { note1Id: noteId }, { note2Id: noteId } ] }, select: { note1Id: true, note2Id: true } }) // Create a set of dismissed note pairs for quick lookup const dismissedPairs = new Set( dismissedInsights.map(i => `${i.note1Id}-${i.note2Id}` ) ) // Get all other user's notes with embeddings const otherNotes = await prisma.note.findMany({ where: { userId, id: { not: noteId }, // Exclude the target note isArchived: false, embedding: { not: null } }, select: { id: true, title: true, content: true, embedding: true, createdAt: true }, orderBy: { createdAt: 'desc' } }) if (otherNotes.length === 0) { return [] } // Parse target note embedding const targetEmbedding = JSON.parse(targetNote.embedding) // Check if user has demo mode enabled const settings = await prisma.userAISettings.findUnique({ where: { userId } }) const demoMode = settings?.demoMode || false const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD const connections: NoteConnection[] = [] // Compare target note with all other notes for (const otherNote of otherNotes) { if (!otherNote.embedding) continue const otherEmbedding = JSON.parse(otherNote.embedding) // Check if this connection was dismissed const pairKey1 = `${targetNote.id}-${otherNote.id}` const pairKey2 = `${otherNote.id}-${targetNote.id}` if (dismissedPairs.has(pairKey1) || dismissedPairs.has(pairKey2)) { continue } // Calculate time difference const daysApart = Math.abs( Math.floor((targetNote.createdAt.getTime() - otherNote.createdAt.getTime()) / (1000 * 60 * 60 * 24)) ) // Time diversity filter if (daysApart < minDaysApart) { continue } // Calculate cosine similarity const similarity = cosineSimilarity(targetEmbedding, otherEmbedding) // Similarity threshold if (similarity >= similarityThreshold) { connections.push({ note1: { id: targetNote.id, title: targetNote.title, content: targetNote.content.substring(0, 200) + (targetNote.content.length > 200 ? '...' : ''), createdAt: targetNote.createdAt }, note2: { id: otherNote.id, title: otherNote.title, content: otherNote.content ? otherNote.content.substring(0, 200) + (otherNote.content.length > 200 ? '...' : '') : '', createdAt: otherNote.createdAt }, similarityScore: similarity, insight: '', // Will be generated on demand daysApart }) } } // Sort by similarity score (descending) connections.sort((a, b) => b.similarityScore - a.similarityScore) return connections } /** * Get insights history for user */ async getInsightsHistory(userId: string): Promise { const insights = await prisma.memoryEchoInsight.findMany({ where: { userId }, include: { note1: { select: { id: true, title: true, content: true } }, note2: { select: { id: true, title: true, content: true } } }, orderBy: { insightDate: 'desc' }, take: 20 }) return insights } } // Export singleton instance export const memoryEchoService = new MemoryEchoService()