/ src / lib / memory / compaction.ts
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  }