memory-consolidation.ts
1 import { getMemoryDb } from '@/lib/server/memory/memory-db' 2 import { loadAgents } from '@/lib/server/storage' 3 import { resolveGenerationModelConfig } from '@/lib/server/build-llm' 4 import { HumanMessage } from '@langchain/core/messages' 5 import { errorMessage } from '@/lib/shared-utils' 6 import { onNextIdleWindow } from '@/lib/server/runtime/idle-window' 7 8 let consolidationRegistered = false 9 let compactionRegistered = false 10 11 /** 12 * Register daily consolidation to run during the next idle window. 13 * The idle-window system guarantees execution within 24h even without idle time. 14 */ 15 export function registerConsolidationIdleCallback(): void { 16 if (consolidationRegistered) return 17 consolidationRegistered = true 18 onNextIdleWindow(async () => { 19 consolidationRegistered = false 20 await runDailyConsolidation() 21 registerConsolidationIdleCallback() 22 }) 23 } 24 25 /** 26 * Register access-based compaction to run during the next idle window. 27 */ 28 export function registerCompactionIdleCallback(): void { 29 if (compactionRegistered) return 30 compactionRegistered = true 31 onNextIdleWindow(async () => { 32 compactionRegistered = false 33 await runAccessBasedCompaction() 34 registerCompactionIdleCallback() 35 }) 36 } 37 38 function canCreateDailyDigestForAgent( 39 agentId: string, 40 agents: ReturnType<typeof loadAgents>, 41 ): boolean { 42 const agent = agents[agentId] 43 if (!agent || agent.trashedAt) return false 44 try { 45 resolveGenerationModelConfig({ agentId }) 46 return true 47 } catch (err: unknown) { 48 const message = errorMessage(err) 49 if (message.includes('No generation-compatible model is configured')) return false 50 throw err 51 } 52 } 53 54 /** 55 * Produce daily digests per agent and prune stale entries. 56 * Only fires when an agent has >5 non-breadcrumb memories in the past 24h 57 * and no digest for today already exists. 58 */ 59 export async function runDailyConsolidation(): Promise<{ 60 digests: number 61 pruned: number 62 deduped: number 63 errors: string[] 64 }> { 65 const memDb = getMemoryDb() 66 const counts = memDb.countsByAgent() 67 const agents = loadAgents({ includeTrashed: true }) 68 const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD 69 const digestTitle = `Daily digest: ${today}` 70 const cutoff24h = Date.now() - 24 * 3600_000 71 const errors: string[] = [] 72 let digestsCreated = 0 73 74 for (const agentKey of Object.keys(counts)) { 75 if (agentKey === '_global') continue 76 const agentId = agentKey 77 78 try { 79 if (!canCreateDailyDigestForAgent(agentId, agents)) continue 80 81 // Check if digest already exists for today 82 const existing = memDb.search(digestTitle, agentId) 83 if (existing.some((m) => m.category === 'daily_digest' && m.title === digestTitle)) continue 84 85 // Fetch recent memories (exclude breadcrumbs and digests) 86 const recent = memDb.getByAgent(agentId, 100) 87 const candidates = recent.filter((m) => { 88 if (m.category === 'breadcrumb' || m.category === 'daily_digest') return false 89 return (m.createdAt || m.updatedAt || 0) >= cutoff24h 90 }) 91 92 if (candidates.length < 5) continue 93 94 // Sort by reinforcement count descending so most-reinforced memories are prioritized in digest 95 candidates.sort((a, b) => (b.reinforcementCount || 0) - (a.reinforcementCount || 0)) 96 97 // Build summarization prompt 98 const memoryLines = candidates.slice(0, 30).map((m) => { 99 const rc = m.reinforcementCount || 0 100 const content = (m.content || '').slice(0, 300) 101 return `- [${m.category}]${rc > 0 ? ` (reinforced x${rc})` : ''} ${m.title}: ${content}` 102 }) 103 104 const prompt = [ 105 'Summarize the following memory entries from the last 24 hours into a concise daily digest.', 106 'Focus on key decisions, discoveries, and outcomes. Skip trivial or redundant entries.', 107 'Format as 3-7 bullet points. Be concise.', 108 '', 109 ...memoryLines, 110 ].join('\n') 111 112 // Use the target agent's configured generation provider 113 const { buildLLM } = await import('@/lib/server/build-llm') 114 const { llm } = await buildLLM({ agentId }) 115 116 const response = await llm.invoke([new HumanMessage(prompt)]) 117 const digestContent = typeof response.content === 'string' 118 ? response.content 119 : Array.isArray(response.content) 120 ? response.content.map((b) => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('') 121 : '' 122 123 if (!digestContent.trim()) continue 124 125 const digestCandidates = candidates.slice(0, 30) 126 const linkedMemoryIds = digestCandidates.slice(0, 10).map((m) => m.id) 127 memDb.add({ 128 agentId, 129 sessionId: null, 130 category: 'daily_digest', 131 title: digestTitle, 132 content: digestContent.trim(), 133 linkedMemoryIds, 134 }) 135 136 // Reset reinforcement counts on entries folded into the digest to prevent double-counting 137 for (const m of digestCandidates) { 138 if (m.reinforcementCount && m.reinforcementCount > 0) { 139 memDb.update(m.id, { reinforcementCount: 0 }) 140 } 141 } 142 143 digestsCreated++ 144 } catch (err: unknown) { 145 errors.push(`Agent ${agentId}: ${errorMessage(err)}`) 146 } 147 } 148 149 // Run maintenance: dedupe + prune stale working entries 150 let pruned = 0 151 let deduped = 0 152 try { 153 const maintenance = memDb.maintain({ dedupe: true, pruneWorking: true, ttlHours: 24 }) 154 pruned = maintenance.pruned 155 deduped = maintenance.deduped 156 } catch (err: unknown) { 157 errors.push(`Maintenance: ${errorMessage(err)}`) 158 } 159 160 return { 161 digests: digestsCreated, 162 pruned, 163 deduped, 164 errors, 165 } 166 } 167 168 /** 169 * Access-pattern-driven memory compaction: 170 * 1. Promote working-tier entries with high access + reinforcement to durable 171 * 2. Archive durable entries with zero access and age > 60 days 172 * 3. Merge frequently co-accessed entries (same agent, 5+ accesses in 7d) into consolidated insights 173 */ 174 export async function runAccessBasedCompaction(): Promise<{ 175 promoted: number 176 archived: number 177 merged: number 178 errors: string[] 179 }> { 180 const memDb = getMemoryDb() 181 const counts = memDb.countsByAgent() 182 const errors: string[] = [] 183 let promoted = 0 184 let archived = 0 185 let merged = 0 186 const now = Date.now() 187 const sixtyDaysAgo = now - 60 * 86_400_000 188 189 for (const agentKey of Object.keys(counts)) { 190 if (agentKey === '_global') continue 191 const agentId = agentKey 192 193 try { 194 const allEntries = memDb.getByAgent(agentId, 500) 195 196 // 1. Promote working → durable 197 for (const entry of allEntries) { 198 const tier = typeof entry.metadata?.tier === 'string' ? entry.metadata.tier : '' 199 if (tier !== 'working' && tier !== '') continue 200 if ((entry.accessCount || 0) >= 3 && (entry.reinforcementCount || 0) >= 2) { 201 memDb.update(entry.id, { 202 metadata: { ...entry.metadata, tier: 'durable' }, 203 }) 204 promoted++ 205 } 206 } 207 208 // 2. Archive stale durable entries 209 for (const entry of allEntries) { 210 const tier = typeof entry.metadata?.tier === 'string' ? entry.metadata.tier : '' 211 if (tier !== 'durable') continue 212 if ((entry.accessCount || 0) === 0 && (entry.updatedAt || entry.createdAt) < sixtyDaysAgo) { 213 memDb.update(entry.id, { 214 metadata: { ...entry.metadata, tier: 'archive' }, 215 }) 216 archived++ 217 } 218 } 219 220 // 3. Merge frequently co-accessed entries into consolidated insights 221 const frequent = memDb.getFrequentlyAccessedByAgent(agentId, 5, 7) 222 if (frequent.length >= 2) { 223 const contentLines = frequent.slice(0, 6).map((m) => { 224 return `- [${m.category}] ${m.title}: ${(m.content || '').slice(0, 200)}` 225 }) 226 const consolidatedContent = `Consolidated insight from ${frequent.length} frequently accessed memories:\n${contentLines.join('\n')}` 227 const linkedIds = frequent.slice(0, 6).map((m) => m.id) 228 229 memDb.add({ 230 agentId, 231 sessionId: null, 232 category: 'consolidated_insight', 233 title: `Consolidated insight: ${new Date().toISOString().slice(0, 10)}`, 234 content: consolidatedContent, 235 linkedMemoryIds: linkedIds, 236 metadata: { tier: 'durable', origin: 'access-compaction', autoWritten: true }, 237 }) 238 merged++ 239 } 240 } catch (err: unknown) { 241 errors.push(`Agent ${agentId}: ${errorMessage(err)}`) 242 } 243 } 244 245 return { promoted, archived, merged, errors } 246 }