- Fix critical bug: used prisma.systemConfig.findFirst() which returned
a single {key,value} record instead of the full config object needed
by getAIProvider(). Replaced with getSystemConfig() that returns all
config as a proper Record<string,string>.
- Add ensureEmbeddings() to auto-generate embeddings for notes that
lack them before searching for connections. This fixes the case where
notes created without an AI provider configured never got embeddings,
making Memory Echo silently return zero connections.
- Restore demo mode polling (15s interval after dismiss) in the
notification component.
- Integrate ComparisonModal and FusionModal in the notification card
with merge button for direct note fusion from the notification.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
585 lines
16 KiB
TypeScript
585 lines
16 KiB
TypeScript
import { getAIProvider } from '../factory'
|
|
import { cosineSimilarity } from '@/lib/utils'
|
|
import { getSystemConfig } from '@/lib/config'
|
|
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
|
|
|
|
/**
|
|
* Generate embeddings for notes that don't have one yet
|
|
*/
|
|
private async ensureEmbeddings(userId: string): Promise<void> {
|
|
const notesWithoutEmbeddings = await prisma.note.findMany({
|
|
where: {
|
|
userId,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
noteEmbedding: { is: null }
|
|
},
|
|
select: { id: true, content: true }
|
|
})
|
|
|
|
if (notesWithoutEmbeddings.length === 0) return
|
|
|
|
try {
|
|
const config = await getSystemConfig()
|
|
const provider = getAIProvider(config)
|
|
|
|
for (const note of notesWithoutEmbeddings) {
|
|
if (!note.content || note.content.trim().length === 0) continue
|
|
try {
|
|
const embedding = await provider.getEmbeddings(note.content)
|
|
if (embedding && embedding.length > 0) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: note.id },
|
|
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
|
|
update: { embedding: JSON.stringify(embedding) }
|
|
})
|
|
}
|
|
} catch {
|
|
// Skip this note, continue with others
|
|
}
|
|
}
|
|
} catch {
|
|
// Provider not configured — nothing we can do
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find meaningful connections between user's notes
|
|
*/
|
|
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
|
// Ensure all notes have embeddings before searching for connections
|
|
await this.ensureEmbeddings(userId)
|
|
|
|
// Get all user's notes with embeddings
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
noteEmbedding: { isNot: null } // Only notes with embeddings
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
content: true,
|
|
noteEmbedding: true,
|
|
createdAt: true
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
})
|
|
|
|
if (notes.length < 2) {
|
|
return [] // Need at least 2 notes to find connections
|
|
}
|
|
|
|
// Parse embeddings (already native Json from PostgreSQL)
|
|
const notesWithEmbeddings = notes
|
|
.map(note => ({
|
|
...note,
|
|
embedding: note.noteEmbedding?.embedding ? JSON.parse(note.noteEmbedding.embedding) as number[] : 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<string> {
|
|
try {
|
|
const config = await getSystemConfig()
|
|
const provider = getAIProvider(config)
|
|
|
|
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<MemoryEchoInsight | null> {
|
|
// 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
|
|
// In demo mode, add milliseconds offset to avoid @@unique([userId, insightDate]) collision
|
|
const insightDateValue = demoMode
|
|
? new Date(Date.now() + Math.floor(Math.random() * 1000))
|
|
: new Date()
|
|
|
|
const insight = await prisma.memoryEchoInsight.create({
|
|
data: {
|
|
userId,
|
|
note1Id: newConnection.note1.id,
|
|
note2Id: newConnection.note2.id,
|
|
similarityScore: newConnection.similarityScore,
|
|
insight: insightText,
|
|
insightDate: insightDateValue,
|
|
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<void> {
|
|
await prisma.memoryEchoInsight.update({
|
|
where: { id: insightId },
|
|
data: { viewed: true }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Submit feedback for insight
|
|
*/
|
|
async submitFeedback(insightId: string, feedback: 'thumbs_up' | 'thumbs_down'): Promise<void> {
|
|
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: {
|
|
timestamp: new Date().toISOString()
|
|
} as any
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all connections for a specific note
|
|
*/
|
|
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
|
|
// Ensure all notes have embeddings before searching
|
|
await this.ensureEmbeddings(userId)
|
|
|
|
// Get the note with embedding
|
|
const targetNote = await prisma.note.findUnique({
|
|
where: { id: noteId },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
content: true,
|
|
noteEmbedding: true,
|
|
createdAt: true,
|
|
userId: true
|
|
}
|
|
})
|
|
|
|
if (!targetNote || targetNote.userId !== userId) {
|
|
return [] // Note not found or doesn't belong to user
|
|
}
|
|
|
|
if (!targetNote.noteEmbedding) {
|
|
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 },
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
noteEmbedding: { isNot: null }
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
content: true,
|
|
noteEmbedding: true,
|
|
createdAt: true
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
})
|
|
|
|
if (otherNotes.length === 0) {
|
|
return []
|
|
}
|
|
|
|
// Target note embedding (already native Json from PostgreSQL)
|
|
const targetEmbedding = targetNote.noteEmbedding?.embedding ? JSON.parse(targetNote.noteEmbedding.embedding) as number[] : null
|
|
if (!targetEmbedding) return []
|
|
|
|
// 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.noteEmbedding) continue
|
|
|
|
const otherEmbedding = otherNote.noteEmbedding?.embedding ? JSON.parse(otherNote.noteEmbedding.embedding) as number[] : null
|
|
if (!otherEmbedding) continue
|
|
|
|
// 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<MemoryEchoInsight[]> {
|
|
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()
|