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