compaction.ts
1 import type { ChatMessage, MemoryItem } from '@/lib/shared/chat' 2 3 export interface MemoryCandidate { 4 key: string 5 value: string 6 category: MemoryItem['category'] 7 confidence: number 8 } 9 10 export function extractMemoryCandidates(input: string): MemoryCandidate[] { 11 const text = input.trim() 12 if (!text) return [] 13 14 const candidates: MemoryCandidate[] = [] 15 16 const patterns: Array<{ 17 regex: RegExp 18 toCandidate: (match: RegExpMatchArray) => MemoryCandidate 19 }> = [ 20 { 21 regex: /\bmy name is\s+([a-z][a-z\s'-]{0,40})/i, 22 toCandidate: (match) => ({ 23 key: 'user_name', 24 value: titleCase(match[1].trim()), 25 category: 'identity', 26 confidence: 0.95, 27 }), 28 }, 29 { 30 regex: /\bi(?:'m| am)\s+([a-z][a-z\s'-]{1,30})\b/i, 31 toCandidate: (match) => ({ 32 key: 'self_description', 33 value: normalizeSpacing(match[1]), 34 category: 'identity', 35 confidence: 0.45, 36 }), 37 }, 38 { 39 regex: /\bmy favorite\s+([a-z\s]{2,20})\s+is\s+([^.!?\n]{1,80})/i, 40 toCandidate: (match) => ({ 41 key: `favorite_${slug(match[1])}`, 42 value: normalizeSpacing(match[2]), 43 category: 'preference', 44 confidence: 0.9, 45 }), 46 }, 47 { 48 regex: /\bi (?:really )?(?:like|love|prefer)\s+([^.!?\n]{1,80})/i, 49 toCandidate: (match) => ({ 50 key: 'likes_general', 51 value: normalizeSpacing(match[1]), 52 category: 'preference', 53 confidence: 0.65, 54 }), 55 }, 56 { 57 regex: /\bmy (?:wife|husband|mom|mother|dad|father|son|daughter|brother|sister)\s+is\s+([^.!?\n]{1,80})/i, 58 toCandidate: (match) => ({ 59 key: 'family_relation_note', 60 value: normalizeSpacing(match[0]), 61 category: 'relationship', 62 confidence: 0.6, 63 }), 64 }, 65 ] 66 67 for (const pattern of patterns) { 68 const match = text.match(pattern.regex) 69 if (!match) continue 70 const candidate = pattern.toCandidate(match) 71 if (candidate.value.length > 0) { 72 candidates.push(candidate) 73 } 74 } 75 76 return dedupeCandidates(candidates) 77 } 78 79 export function buildCompactionSummary(messages: Pick<ChatMessage, 'role' | 'text'>[]): string { 80 const recent = messages.filter((message) => message.text.trim()) 81 if (recent.length === 0) return '' 82 83 const bullets = recent.slice(-12).map((message) => { 84 const prefix = message.role === 'assistant' ? 'Assistant' : 'User' 85 return `- ${prefix}: ${truncate(normalizeSpacing(message.text), 180)}` 86 }) 87 88 return ['Conversation summary (compacted context):', ...bullets].join('\n') 89 } 90 91 export function mergeCompactedSummaries(existingSummary: string, newSummary: string): string { 92 if (!newSummary.trim()) return existingSummary.trim() 93 if (!existingSummary.trim()) return newSummary.trim() 94 95 return `${existingSummary.trim()}\n\n${newSummary.trim()}`.slice(-6000) 96 } 97 98 function dedupeCandidates(candidates: MemoryCandidate[]): MemoryCandidate[] { 99 const byKey = new Map<string, MemoryCandidate>() 100 101 for (const candidate of candidates) { 102 const current = byKey.get(candidate.key) 103 if (!current || candidate.confidence >= current.confidence) { 104 byKey.set(candidate.key, candidate) 105 } 106 } 107 108 return [...byKey.values()] 109 } 110 111 function normalizeSpacing(value: string): string { 112 return value.replace(/\s+/g, ' ').trim() 113 } 114 115 function truncate(value: string, limit: number): string { 116 if (value.length <= limit) return value 117 return `${value.slice(0, limit - 1).trimEnd()}…` 118 } 119 120 function slug(value: string): string { 121 return value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') 122 } 123 124 function titleCase(value: string): string { 125 return value 126 .split(/\s+/) 127 .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1).toLowerCase()) 128 .join(' ') 129 }