main
py 772 lines 32.2 KB
Raw
1 import asyncio
2 import json
3 from dataclasses import dataclass, field
4 from typing import Any, Dict, List, Optional
5 from enum import Enum
6
7 from langchain_core.documents import Document
8
9 from plugins._memory.helpers.memory import Memory
10 from helpers.dirty_json import DirtyJson
11 from helpers.localization import Localization
12 from helpers.log import LogItem
13 from helpers.print_style import PrintStyle
14 from agent import Agent
15 from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
16
17
18 class ConsolidationAction(Enum):
19 """Actions that can be taken during memory consolidation."""
20 MERGE = "merge"
21 REPLACE = "replace"
22 KEEP_SEPARATE = "keep_separate"
23 UPDATE = "update"
24 SKIP = "skip"
25
26
27 @dataclass
28 class ConsolidationConfig:
29 """Configuration for memory consolidation behavior."""
30 similarity_threshold: float = DEFAULT_MEMORY_THRESHOLD
31 max_similar_memories: int = 10
32 consolidation_sys_prompt: str = "memory.consolidation.sys.md"
33 consolidation_msg_prompt: str = "memory.consolidation.msg.md"
34 max_llm_context_memories: int = 5
35 keyword_extraction_sys_prompt: str = "memory.keyword_extraction.sys.md"
36 keyword_extraction_msg_prompt: str = "memory.keyword_extraction.msg.md"
37 processing_timeout_seconds: int = 60
38 # Add safety threshold for REPLACE actions
39 replace_similarity_threshold: float = 0.75 # Threshold tuned for real cosine similarity scores
40
41
42 @dataclass
43 class ConsolidationResult:
44 """Result of memory consolidation analysis."""
45 action: ConsolidationAction
46 memories_to_remove: List[str] = field(default_factory=list)
47 memories_to_update: List[Dict[str, Any]] = field(default_factory=list)
48 new_memory_content: str = ""
49 metadata: Dict[str, Any] = field(default_factory=dict)
50 reasoning: str = ""
51
52
53 @dataclass
54 class MemoryAnalysisContext:
55 """Context for LLM memory analysis."""
56 new_memory: str
57 similar_memories: List[Document]
58 area: str
59 timestamp: str
60 existing_metadata: Dict[str, Any]
61
62
63 class MemoryConsolidator:
64 """
65 Intelligent memory consolidation system that uses LLM analysis to determine
66 optimal memory organization and automatically consolidates related memories.
67 """
68
69 def __init__(self, agent: Agent, config: Optional[ConsolidationConfig] = None):
70 self.agent = agent
71 self.config = config or ConsolidationConfig()
72
73 async def process_new_memory(
74 self,
75 new_memory: str,
76 area: str,
77 metadata: Dict[str, Any],
78 log_item: Optional[LogItem] = None
79 ) -> dict:
80 """
81 Process a new memory through the intelligent consolidation pipeline.
82
83 Args:
84 new_memory: The new memory content to process
85 area: Memory area (MAIN, FRAGMENTS, SOLUTIONS)
86 metadata: Initial metadata for the memory
87 log_item: Optional log item for progress tracking
88
89 Returns:
90 dict: {"success": bool, "memory_ids": [str, ...]}
91 """
92 try:
93 # Start processing with timeout
94 processing_task = asyncio.create_task(
95 self._process_memory_with_consolidation(new_memory, area, metadata, log_item)
96 )
97
98 result = await asyncio.wait_for(
99 processing_task,
100 timeout=self.config.processing_timeout_seconds
101 )
102 return result
103
104 except asyncio.TimeoutError:
105 PrintStyle().error(f"Memory consolidation timeout for area {area}")
106 return {"success": False, "memory_ids": []}
107
108 except Exception as e:
109 PrintStyle().error(f"Memory consolidation error for area {area}: {str(e)}")
110 return {"success": False, "memory_ids": []}
111
112 async def _process_memory_with_consolidation(
113 self,
114 new_memory: str,
115 area: str,
116 metadata: Dict[str, Any],
117 log_item: Optional[LogItem] = None
118 ) -> dict:
119 """Execute the full consolidation pipeline."""
120
121 if log_item:
122 log_item.update(progress="Starting intelligent memory consolidation...")
123
124 # Step 1: Discover similar memories
125 similar_memories = await self._find_similar_memories(new_memory, area, log_item)
126
127 # this block always returns
128 if not similar_memories:
129 # No similar memories found, insert directly
130 if log_item:
131 log_item.update(
132 progress="No similar memories found, inserting new memory",
133 )
134 try:
135 db = await Memory.get(self.agent)
136 if 'timestamp' not in metadata:
137 metadata['timestamp'] = self._get_timestamp()
138 memory_id = await db.insert_text(new_memory, metadata)
139 if log_item:
140 log_item.update(
141 result="Memory inserted successfully",
142 memory_ids=[memory_id],
143 consolidation_action="direct_insert"
144 )
145 return {"success": True, "memory_ids": [memory_id]}
146 except Exception as e:
147 PrintStyle().error(f"Direct memory insertion failed: {str(e)}")
148 if log_item:
149 log_item.update(result=f"Memory insertion failed: {str(e)}")
150 return {"success": False, "memory_ids": []}
151
152 if log_item:
153 log_item.update(
154 progress=f"Found {len(similar_memories)} similar memories, analyzing...",
155 similar_memories_count=len(similar_memories)
156 )
157
158 # Step 2: Validate that similar memories still exist (they might have been deleted by previous consolidations)
159 if similar_memories:
160 memory_ids_to_check = [doc.metadata.get('id') for doc in similar_memories if doc.metadata.get('id')]
161 # Filter out None values and ensure all IDs are strings
162 memory_ids_to_check = [str(id) for id in memory_ids_to_check if id is not None]
163 db = await Memory.get(self.agent)
164 still_existing = db.db.get_by_ids(memory_ids_to_check)
165 existing_ids = {doc.metadata.get('id') for doc in still_existing}
166
167 # Filter out deleted memories
168 valid_similar_memories = [doc for doc in similar_memories if doc.metadata.get('id') in existing_ids]
169
170 if len(valid_similar_memories) != len(similar_memories):
171 deleted_count = len(similar_memories) - len(valid_similar_memories)
172 if log_item:
173 log_item.update(
174 progress=f"Filtered out {deleted_count} deleted memories, {len(valid_similar_memories)} remain for analysis",
175 race_condition_detected=True,
176 deleted_similar_memories_count=deleted_count
177 )
178 similar_memories = valid_similar_memories
179
180 # If no valid similar memories remain after filtering, insert directly
181 if not similar_memories:
182 if log_item:
183 log_item.update(
184 progress="No valid similar memories remain, inserting new memory",
185 )
186 try:
187 db = await Memory.get(self.agent)
188 if 'timestamp' not in metadata:
189 metadata['timestamp'] = self._get_timestamp()
190 memory_id = await db.insert_text(new_memory, metadata)
191 if log_item:
192 log_item.update(
193 result="Memory inserted successfully (no valid similar memories)",
194 memory_ids=[memory_id],
195 consolidation_action="direct_insert_filtered"
196 )
197 return {"success": True, "memory_ids": [memory_id]}
198 except Exception as e:
199 PrintStyle().error(f"Direct memory insertion failed: {str(e)}")
200 if log_item:
201 log_item.update(result=f"Memory insertion failed: {str(e)}")
202 return {"success": False, "memory_ids": []}
203
204 # Step 3: Analyze with LLM (now with validated memories)
205 analysis_context = MemoryAnalysisContext(
206 new_memory=new_memory,
207 similar_memories=similar_memories,
208 area=area,
209 timestamp=self._get_timestamp(),
210 existing_metadata=metadata
211 )
212
213 consolidation_result = await self._analyze_memory_consolidation(analysis_context, log_item)
214
215 if consolidation_result.action == ConsolidationAction.SKIP:
216 if log_item:
217 log_item.update(
218 progress="LLM analysis suggests skipping consolidation",
219 )
220 try:
221 db = await Memory.get(self.agent)
222 if 'timestamp' not in metadata:
223 metadata['timestamp'] = self._get_timestamp()
224 memory_id = await db.insert_text(new_memory, metadata)
225 if log_item:
226 log_item.update(
227 result="Memory inserted (consolidation skipped)",
228 memory_ids=[memory_id],
229 consolidation_action="skip",
230 reasoning=consolidation_result.reasoning or "LLM analysis suggested skipping"
231 )
232 return {"success": True, "memory_ids": [memory_id]}
233 except Exception as e:
234 PrintStyle().error(f"Skip consolidation insertion failed: {str(e)}")
235 if log_item:
236 log_item.update(result=f"Memory insertion failed: {str(e)}")
237 return {"success": False, "memory_ids": []}
238
239 # Step 4: Apply consolidation decisions
240 memory_ids = await self._apply_consolidation_result(
241 consolidation_result,
242 area,
243 analysis_context.existing_metadata, # Pass original metadata
244 log_item
245 )
246
247 if log_item:
248 if memory_ids:
249 log_item.update(
250 result=f"Consolidation completed: {consolidation_result.action.value}",
251 memory_ids=memory_ids,
252 consolidation_action=consolidation_result.action.value,
253 reasoning=consolidation_result.reasoning or "No specific reasoning provided",
254 memories_processed=len(similar_memories) + 1 # +1 for new memory
255 )
256 else:
257 log_item.update(
258 result=f"Consolidation failed: {consolidation_result.action.value}",
259 consolidation_action=consolidation_result.action.value,
260 reasoning=consolidation_result.reasoning or "Consolidation operation failed"
261 )
262
263 return {"success": bool(memory_ids), "memory_ids": memory_ids or []}
264
265 async def _gather_consolidated_metadata(
266 self,
267 db: Memory,
268 result: ConsolidationResult,
269 original_metadata: Dict[str, Any]
270 ) -> Dict[str, Any]:
271 """
272 Gather and merge metadata from memories being consolidated to preserve important fields.
273 This ensures critical metadata like priority, source, etc. is preserved during consolidation.
274 """
275 try:
276 # Start with the new memory's metadata as base
277 consolidated_metadata = dict(original_metadata)
278
279 # Collect all memory IDs that will be involved in consolidation
280 memory_ids = []
281
282 # Add memories to be removed (MERGE, REPLACE actions)
283 if result.memories_to_remove:
284 memory_ids.extend(result.memories_to_remove)
285
286 # Add memories to be updated (UPDATE action)
287 if result.memories_to_update:
288 for update_info in result.memories_to_update:
289 memory_id = update_info.get('id')
290 if memory_id:
291 memory_ids.append(memory_id)
292
293 # Retrieve original memories to extract their metadata
294 if memory_ids:
295 original_memories = await db.db.aget_by_ids(memory_ids)
296
297 # Merge ALL metadata fields from original memories
298 for memory in original_memories:
299 memory_metadata = memory.metadata
300
301 # Process ALL metadata fields from the original memory
302 for field_name, field_value in memory_metadata.items():
303 if field_name not in consolidated_metadata:
304 # Field doesn't exist in consolidated metadata, add it
305 consolidated_metadata[field_name] = field_value
306 elif field_name in consolidated_metadata:
307 # Field exists in both - handle special merge cases
308 if field_name == 'tags' and isinstance(field_value, list) and isinstance(consolidated_metadata[field_name], list):
309 # Merge tags lists and remove duplicates
310 merged_tags = list(set(consolidated_metadata[field_name] + field_value))
311 consolidated_metadata[field_name] = merged_tags
312 # For all other fields, keep the new memory's value (don't overwrite)
313 # This preserves the new memory's metadata when there are conflicts
314
315 return consolidated_metadata
316
317 except Exception as e:
318 # If metadata gathering fails, return original metadata as fallback
319 PrintStyle(font_color="yellow").print(f"Failed to gather consolidated metadata: {str(e)}")
320 return original_metadata
321
322 async def _find_similar_memories(
323 self,
324 new_memory: str,
325 area: str,
326 log_item: Optional[LogItem] = None
327 ) -> List[Document]:
328 """
329 Find similar memories using both semantic similarity and keyword matching.
330 Now includes knowledge source awareness and similarity scores for validation.
331 """
332 db = await Memory.get(self.agent)
333
334 # Step 1: Extract keywords/queries for enhanced search
335 search_queries = await self._extract_search_keywords(new_memory, log_item)
336
337 all_similar = []
338
339 # Step 2: Semantic similarity search with real scores
340 semantic_results = await db.search_similarity_threshold_with_scores(
341 query=new_memory,
342 limit=self.config.max_similar_memories,
343 threshold=self.config.similarity_threshold,
344 filter=f"area == '{area}'"
345 )
346 for doc, score in semantic_results:
347 doc.metadata['_consolidation_similarity'] = float(score) if score is not None else 0.0
348 all_similar.append(doc)
349
350 # Step 3: Keyword-based searches with real scores
351 for query in search_queries:
352 if query.strip():
353 queries_count = max(1, len(search_queries))
354 keyword_results = await db.search_similarity_threshold_with_scores(
355 query=query.strip(),
356 limit=max(3, self.config.max_similar_memories // queries_count),
357 threshold=self.config.similarity_threshold,
358 filter=f"area == '{area}'"
359 )
360 for doc, score in keyword_results:
361 doc.metadata['_consolidation_similarity'] = float(score) if score is not None else 0.0
362 all_similar.append(doc)
363
364 # Step 4: Deduplicate by document ID, keep highest score per memory ID
365 best_by_id: Dict[str, Document] = {}
366 for doc in all_similar:
367 doc_id = doc.metadata.get('id')
368 if doc_id:
369 existing = best_by_id.get(doc_id)
370 if (
371 existing is None
372 or doc.metadata.get('_consolidation_similarity', 0)
373 > existing.metadata.get('_consolidation_similarity', 0)
374 ):
375 best_by_id[doc_id] = doc
376 unique_similar = list(best_by_id.values())
377
378 # Step 5: Sort by similarity score descending
379 unique_similar.sort(
380 key=lambda d: d.metadata.get('_consolidation_similarity', 0),
381 reverse=True
382 )
383
384 # Step 6: Limit to max context for LLM
385 limited_similar = unique_similar[:self.config.max_llm_context_memories]
386
387 return limited_similar
388
389 async def _extract_search_keywords(
390 self,
391 new_memory: str,
392 log_item: Optional[LogItem] = None
393 ) -> List[str]:
394 """Extract search keywords/queries from new memory using utility LLM."""
395
396 try:
397 system_prompt = self.agent.read_prompt(
398 self.config.keyword_extraction_sys_prompt,
399 )
400
401 message_prompt = self.agent.read_prompt(
402 self.config.keyword_extraction_msg_prompt,
403 memory_content=new_memory
404 )
405
406 # Call utility LLM to extract search queries
407 keywords_response = await self.agent.call_utility_model(
408 system=system_prompt,
409 message=message_prompt,
410 background=True
411 )
412
413 # Parse the response - expect JSON array of strings
414 keywords_json = DirtyJson.parse_string(keywords_response.strip())
415
416 if isinstance(keywords_json, list):
417 return [str(k) for k in keywords_json if k]
418 elif isinstance(keywords_json, str):
419 return [keywords_json]
420 else:
421 return []
422
423 except Exception as e:
424 PrintStyle().warning(f"Keyword extraction failed: {str(e)}")
425 # Fallback: use intelligent truncation for search
426 # Take first 200 chars if short, or first sentence if longer, but cap at 200 chars
427 if len(new_memory) <= 200:
428 fallback_content = new_memory
429 else:
430 first_sentence = new_memory.split('.')[0]
431 fallback_content = first_sentence[:200] if len(first_sentence) <= 200 else new_memory[:200]
432 return [fallback_content.strip()]
433
434 async def _analyze_memory_consolidation(
435 self,
436 context: MemoryAnalysisContext,
437 log_item: Optional[LogItem] = None
438 ) -> ConsolidationResult:
439 """Use LLM to analyze memory consolidation options."""
440
441 try:
442 # Prepare similar memories text
443 similar_memories_text = ""
444 for i, doc in enumerate(context.similar_memories):
445 timestamp = doc.metadata.get('timestamp', 'unknown')
446 doc_id = doc.metadata.get('id', f'doc_{i}')
447 similar_memories_text += f"ID: {doc_id}\nTimestamp: {timestamp}\nContent: {doc.page_content}\n\n"
448
449 # Build system prompt
450 system_prompt = self.agent.read_prompt(
451 self.config.consolidation_sys_prompt,
452 )
453
454 # Build message prompt
455 message_prompt = self.agent.read_prompt(
456 self.config.consolidation_msg_prompt,
457 new_memory=context.new_memory,
458 similar_memories=similar_memories_text.strip(),
459 area=context.area,
460 current_timestamp=context.timestamp,
461 new_memory_metadata=json.dumps(context.existing_metadata, indent=2)
462 )
463
464 analysis_response = await self.agent.call_utility_model(
465 system=system_prompt,
466 message=message_prompt,
467 callback=None,
468 background=True
469 )
470
471 # Parse LLM response
472 result_json = DirtyJson.parse_string(analysis_response.strip())
473
474 if not isinstance(result_json, dict):
475 raise ValueError("LLM response is not a valid JSON object")
476
477 # Parse consolidation result
478 action_str = result_json.get('action', 'skip')
479 try:
480 action = ConsolidationAction(action_str.lower())
481 except ValueError:
482 action = ConsolidationAction.SKIP
483
484 # Determine appropriate fallback for new_memory_content based on action
485 if action in [ConsolidationAction.MERGE, ConsolidationAction.REPLACE]:
486 # For MERGE/REPLACE, if no content provided, it's an error - don't use original
487 default_content = ""
488 else:
489 # For KEEP_SEPARATE/UPDATE/SKIP, original memory is appropriate fallback
490 default_content = context.new_memory
491
492 return ConsolidationResult(
493 action=action,
494 memories_to_remove=result_json.get('memories_to_remove', []),
495 memories_to_update=result_json.get('memories_to_update', []),
496 new_memory_content=result_json.get('new_memory_content', default_content),
497 metadata=result_json.get('metadata', {}),
498 reasoning=result_json.get('reasoning', '')
499 )
500
501 except Exception as e:
502 PrintStyle().warning(f"LLM consolidation analysis failed: {str(e)}")
503 # Fallback: skip consolidation
504 return ConsolidationResult(
505 action=ConsolidationAction.SKIP,
506 reasoning=f"Analysis failed: {str(e)}"
507 )
508
509 async def _apply_consolidation_result(
510 self,
511 result: ConsolidationResult,
512 area: str,
513 original_metadata: Dict[str, Any], # Add original metadata parameter
514 log_item: Optional[LogItem] = None
515 ) -> list:
516 """Apply the consolidation decisions to the memory database."""
517
518 try:
519 db = await Memory.get(self.agent)
520
521 # Retrieve metadata from memories being consolidated to preserve important fields
522 consolidated_metadata = await self._gather_consolidated_metadata(db, result, original_metadata)
523
524 # Handle each action type specifically
525 if result.action == ConsolidationAction.KEEP_SEPARATE:
526 return await self._handle_keep_separate(db, result, area, consolidated_metadata, log_item)
527
528 elif result.action == ConsolidationAction.MERGE:
529 return await self._handle_merge(db, result, area, consolidated_metadata, log_item)
530
531 elif result.action == ConsolidationAction.REPLACE:
532 return await self._handle_replace(db, result, area, consolidated_metadata, log_item)
533
534 elif result.action == ConsolidationAction.UPDATE:
535 return await self._handle_update(db, result, area, consolidated_metadata, log_item)
536
537 else:
538 # Should not reach here, but handle gracefully
539 PrintStyle().warning(f"Unknown consolidation action: {result.action}")
540 return []
541
542 except Exception as e:
543 PrintStyle().error(f"Failed to apply consolidation result: {str(e)}")
544 return []
545
546 async def _handle_keep_separate(
547 self,
548 db: Memory,
549 result: ConsolidationResult,
550 area: str,
551 original_metadata: Dict[str, Any], # Add original metadata parameter
552 log_item: Optional[LogItem] = None
553 ) -> list:
554 """Handle KEEP_SEPARATE action: Insert new memory without touching existing ones."""
555
556 if not result.new_memory_content:
557 return []
558
559 # Prepare metadata for new memory
560 # LLM metadata takes precedence over original metadata when there are conflicts
561 final_metadata = {
562 'area': area,
563 'timestamp': self._get_timestamp(),
564 'consolidation_action': result.action.value,
565 **original_metadata, # Original metadata first
566 **result.metadata # LLM metadata second (wins conflicts)
567 }
568
569 # do not include reasoning in memory
570 # if result.reasoning:
571 # final_metadata['consolidation_reasoning'] = result.reasoning
572
573 new_id = await db.insert_text(result.new_memory_content, final_metadata)
574 return [new_id]
575
576 async def _handle_merge(
577 self,
578 db: Memory,
579 result: ConsolidationResult,
580 area: str,
581 original_metadata: Dict[str, Any], # Add original metadata parameter
582 log_item: Optional[LogItem] = None
583 ) -> list:
584 """Handle MERGE action: Combine memories, remove originals, insert consolidated version."""
585
586 # Step 1: Remove original memories being merged
587 if result.memories_to_remove:
588 await db.delete_documents_by_ids(result.memories_to_remove)
589
590 # Step 2: Insert consolidated memory
591 if result.new_memory_content:
592 # LLM metadata takes precedence over original metadata when there are conflicts
593 final_metadata = {
594 'area': area,
595 'timestamp': self._get_timestamp(),
596 'consolidation_action': result.action.value,
597 'consolidated_from': result.memories_to_remove,
598 **original_metadata, # Original metadata first
599 **result.metadata # LLM metadata second (wins conflicts)
600 }
601
602 # do not include reasoning in memory
603 # if result.reasoning:
604 # final_metadata['consolidation_reasoning'] = result.reasoning
605
606 new_id = await db.insert_text(result.new_memory_content, final_metadata)
607 return [new_id]
608 else:
609 return []
610
611 async def _handle_replace(
612 self,
613 db: Memory,
614 result: ConsolidationResult,
615 area: str,
616 original_metadata: Dict[str, Any], # Add original metadata parameter
617 log_item: Optional[LogItem] = None
618 ) -> list:
619 """Handle REPLACE action: Remove old memories, insert new version with similarity validation."""
620
621 # Step 1: Validate similarity scores for replacement safety
622 if result.memories_to_remove:
623 # Get the memories to be removed and check their similarity scores
624 memories_to_check = await db.db.aget_by_ids(result.memories_to_remove)
625
626 unsafe_replacements = []
627 for memory in memories_to_check:
628 similarity = memory.metadata.get('_consolidation_similarity', 0.7)
629 if similarity < self.config.replace_similarity_threshold:
630 unsafe_replacements.append({
631 'id': memory.metadata.get('id'),
632 'similarity': similarity,
633 'content_preview': memory.page_content[:100]
634 })
635
636 # If we have unsafe replacements, either block them or require explicit confirmation
637 if unsafe_replacements:
638 PrintStyle().warning(
639 f"REPLACE blocked: {len(unsafe_replacements)} memories below "
640 f"similarity threshold {self.config.replace_similarity_threshold}, converting to KEEP_SEPARATE"
641 )
642
643 # Instead of replace, just insert the new memory (keep separate)
644 if result.new_memory_content:
645 final_metadata = {
646 'area': area,
647 'timestamp': self._get_timestamp(),
648 'consolidation_action': 'keep_separate_safety', # Indicate safety conversion
649 'original_action': 'replace',
650 'safety_reason': f'Similarity below threshold {self.config.replace_similarity_threshold}',
651 **original_metadata,
652 **result.metadata
653 }
654
655 # do not include reasoning in memory
656 # if result.reasoning:
657 # final_metadata['consolidation_reasoning'] = result.reasoning
658
659 new_id = await db.insert_text(result.new_memory_content, final_metadata)
660 return [new_id]
661 else:
662 return []
663
664 # Step 2: Proceed with normal replacement if similarity checks pass
665 if result.memories_to_remove:
666 await db.delete_documents_by_ids(result.memories_to_remove)
667
668 # Step 3: Insert replacement memory
669 if result.new_memory_content:
670 # LLM metadata takes precedence over original metadata when there are conflicts
671 final_metadata = {
672 'area': area,
673 'timestamp': self._get_timestamp(),
674 'consolidation_action': result.action.value,
675 'replaced_memories': result.memories_to_remove,
676 **original_metadata, # Original metadata first
677 **result.metadata # LLM metadata second (wins conflicts)
678 }
679
680 # do not include reasoning in memory
681 # if result.reasoning:
682 # final_metadata['consolidation_reasoning'] = result.reasoning
683
684 new_id = await db.insert_text(result.new_memory_content, final_metadata)
685 return [new_id]
686 else:
687 return []
688
689 async def _handle_update(
690 self,
691 db: Memory,
692 result: ConsolidationResult,
693 area: str,
694 original_metadata: Dict[str, Any], # Add original metadata parameter
695 log_item: Optional[LogItem] = None
696 ) -> list:
697 """Handle UPDATE action: Modify existing memories in place with additional information."""
698
699 updated_count = 0
700 updated_ids = []
701
702 # Step 1: Update existing memories
703 for update_info in result.memories_to_update:
704 memory_id = update_info.get('id')
705 new_content = update_info.get('new_content', '')
706
707 if memory_id and new_content:
708 # Validate that the memory exists before attempting to delete it
709 existing_docs = await db.db.aget_by_ids([memory_id])
710 if not existing_docs:
711 PrintStyle().warning(f"Memory ID {memory_id} not found during update, skipping")
712 continue
713
714 # Delete old version and insert updated version
715 await db.delete_documents_by_ids([memory_id])
716
717 # LLM metadata takes precedence over original metadata when there are conflicts
718 updated_metadata = {
719 'area': area,
720 'timestamp': self._get_timestamp(),
721 'consolidation_action': result.action.value,
722 'updated_from': memory_id,
723 **original_metadata, # Original metadata first
724 **update_info.get('metadata', {}) # LLM metadata second (wins conflicts)
725 }
726
727 new_id = await db.insert_text(new_content, updated_metadata)
728 updated_count += 1
729 updated_ids.append(new_id)
730
731 # Step 2: Insert the new memory only when no existing memory was updated.
732 # UPDATE means "repopulate the existing subject", not "append another
733 # equally-important memory". This keeps mutable facts from piling up.
734 new_memory_id = None
735 if result.new_memory_content and not updated_ids:
736 # LLM metadata takes precedence over original metadata when there are conflicts
737 final_metadata = {
738 'area': area,
739 'timestamp': self._get_timestamp(),
740 'consolidation_action': result.action.value,
741 **original_metadata, # Original metadata first
742 **result.metadata # LLM metadata second (wins conflicts)
743 }
744
745 # do not include reasoning in memory
746 # if result.reasoning:
747 # final_metadata['consolidation_reasoning'] = result.reasoning
748
749 new_memory_id = await db.insert_text(result.new_memory_content, final_metadata)
750 updated_ids.append(new_memory_id)
751
752 return updated_ids
753
754 def _get_timestamp(self) -> str:
755 """Get current timestamp in standard format."""
756 return Localization.get().now_iso(timespec="seconds")
757
758
759 # Factory function for easy instantiation
760 def create_memory_consolidator(agent: Agent, **config_overrides) -> MemoryConsolidator:
761 """
762 Create a MemoryConsolidator with optional configuration overrides.
763
764 Available configuration options:
765 - similarity_threshold: Discovery threshold for finding related memories (default 0.7)
766 - replace_similarity_threshold: Safety threshold for REPLACE actions (default 0.75)
767 - max_similar_memories: Maximum memories to discover (default 10)
768 - max_llm_context_memories: Maximum memories to send to LLM (default 5)
769 - processing_timeout_seconds: Timeout for consolidation processing (default 30)
770 """
771 config = ConsolidationConfig(**config_overrides)
772 return MemoryConsolidator(agent, config)