memory: harden FAISS integrity and consolidation scoring

- Add FAISS index integrity checks using a SHA-256 sidecar (`index.faiss.sha256`) and write hash on save. - Harden `memory_load` filter evaluation with input validation (allowlist + length cap) and `simple_eval(..., functions={})`. - Add score-preserving similarity search and use real relevance scores in consolidation (including best-score dedupe by memory id). - Prevent utility-model context overflows by truncating memorize input history for fragments and solutions.

Alessandro committed Apr 8, 2026 at 13:51 UTC 1cbecc241e89a4ac4f51579e75a20537974440e1
5 files changed +112 -64
plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py
+4
@@ -50,6 +50,10 @@ class MemorizeMemories(Extension):
50 # get system message and chat history for util llm
51 system = self.agent.read_prompt("memory.memories_sum.sys.md")
52 msgs_text = self.agent.concat_messages(self.agent.history)
53 + # Keep only recent context to avoid utility-model context-window overflow.
54 + MAX_MSGS_CHARS = 80000
55 + if len(msgs_text) > MAX_MSGS_CHARS:
56 + msgs_text = msgs_text[-MAX_MSGS_CHARS:]
57
58 # # log query streamed by LLM
59 # async def log_callback(content):
plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py
+4
@@ -51,6 +51,10 @@ class MemorizeSolutions(Extension):
51 # get system message and chat history for util llm
52 system = self.agent.read_prompt("memory.solutions_sum.sys.md")
53 msgs_text = self.agent.concat_messages(self.agent.history)
54 + # Keep only recent context to avoid utility-model context-window overflow.
55 + MAX_MSGS_CHARS = 80000
56 + if len(msgs_text) > MAX_MSGS_CHARS:
57 + msgs_text = msgs_text[-MAX_MSGS_CHARS:]
58
59 # log query streamed by LLM
60 # async def log_callback(content):
plugins/_memory/helpers/memory.py
+69 -10
@@ -18,7 +18,7 @@ from langchain_community.vectorstores.utils import (
18 )
19 from langchain_core.embeddings import Embeddings
20
21 -import os, json
21 +import os, json, hashlib, re
22
23 import numpy as np
24
@@ -178,14 +178,19 @@ class Memory:
178
179 # if db folder exists and is not empty:
180 if os.path.exists(db_dir) and files.exists(db_dir, "index.faiss"):
181 - db = MyFaiss.load_local(
182 - folder_path=db_dir,
183 - embeddings=embedder,
184 - allow_dangerous_deserialization=True,
185 - distance_strategy=DistanceStrategy.COSINE,
186 - # normalize_L2=True,
187 - relevance_score_fn=Memory._cosine_normalizer,
188 - ) # type: ignore
181 + if not Memory._verify_index_hash(db_dir):
182 + PrintStyle(font_color="yellow").print(
183 + f"FAISS index hash mismatch in '{db_dir}' — index will be rebuilt."
184 + )
185 + else:
186 + db = MyFaiss.load_local(
187 + folder_path=db_dir,
188 + embeddings=embedder,
189 + allow_dangerous_deserialization=True,
190 + distance_strategy=DistanceStrategy.COSINE,
191 + # normalize_L2=True,
192 + relevance_score_fn=Memory._cosine_normalizer,
193 + ) # type: ignore
194
195 # if there is a mismatch in embeddings used, re-index the whole DB
196 emb_ok = False
@@ -345,6 +350,18 @@ class Memory:
350 filter=comparator,
351 )
352
353 + async def search_similarity_threshold_with_scores(
354 + self, query: str, limit: int, threshold: float, filter: str = ""
355 + ) -> list[tuple[Document, float]]:
356 + comparator = Memory._get_comparator(filter) if filter else None
357 +
358 + return await self.db.asimilarity_search_with_relevance_scores(
359 + query,
360 + k=limit,
361 + score_threshold=threshold,
362 + filter=comparator,
363 + )
364 +
365 async def delete_documents_by_query(
366 self, query: str, threshold: float, filter: str = ""
367 ):
@@ -432,12 +449,54 @@ class Memory:
449 def _save_db_file(db: MyFaiss, memory_subdir: str):
450 abs_dir = abs_db_dir(memory_subdir)
451 db.save_local(folder_path=abs_dir)
452 + Memory._write_index_hash(abs_dir)
453 +
454 + @staticmethod
455 + def _write_index_hash(abs_dir: str) -> None:
456 + faiss_path = os.path.join(abs_dir, "index.faiss")
457 + hash_path = os.path.join(abs_dir, "index.faiss.sha256")
458 + try:
459 + h = hashlib.sha256()
460 + with open(faiss_path, "rb") as f:
461 + for chunk in iter(lambda: f.read(65536), b""):
462 + h.update(chunk)
463 + with open(hash_path, "w") as f:
464 + f.write(h.hexdigest())
465 + except Exception as e:
466 + PrintStyle(font_color="yellow").print(f"Warning: could not write FAISS hash: {e}")
467 +
468 + @staticmethod
469 + def _verify_index_hash(abs_dir: str) -> bool:
470 + faiss_path = os.path.join(abs_dir, "index.faiss")
471 + hash_path = os.path.join(abs_dir, "index.faiss.sha256")
472 + if not os.path.exists(hash_path):
473 + return True
474 + try:
475 + with open(hash_path, "r") as f:
476 + stored = f.read().strip()
477 + h = hashlib.sha256()
478 + with open(faiss_path, "rb") as f:
479 + for chunk in iter(lambda: f.read(65536), b""):
480 + h.update(chunk)
481 + return h.hexdigest() == stored
482 + except Exception as e:
483 + PrintStyle(font_color="yellow").print(f"Warning: FAISS hash check failed: {e}")
484 + return True
485
486 @staticmethod
487 def _get_comparator(condition: str):
488 + _FILTER_SAFE = re.compile(
489 + r"^[a-zA-Z0-9_\-\.\ \t'\"=<>!()\[\],:\+]+$"
490 + )
491 + if len(condition) > 512 or not _FILTER_SAFE.match(condition):
492 + PrintStyle.error(
493 + f"Memory filter rejected (unsafe characters or too long): {condition!r}"
494 + )
495 + return lambda _data: False
496 +
497 def comparator(data: dict[str, Any]):
498 try:
440 - result = simple_eval(condition, names=data)
499 + result = simple_eval(condition, names=data, functions={})
500 return result
501 except Exception as e:
502 PrintStyle.error(f"Error evaluating condition: {e}")
plugins/_memory/helpers/memory_consolidation.py
+30 -51
@@ -36,7 +36,7 @@ class ConsolidationConfig:
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.9 # Higher threshold for replacement safety
39 + replace_similarity_threshold: float = 0.75 # Threshold tuned for real cosine similarity scores
40
41
42 @dataclass
@@ -336,73 +336,52 @@ class MemoryConsolidator:
336
337 all_similar = []
338
339 - # Step 2: Semantic similarity search with scores
340 - semantic_similar = await db.search_similarity_threshold(
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 - all_similar.extend(semantic_similar)
346 + for doc, score in semantic_results:
347 + doc.metadata['_consolidation_similarity'] = score
348 + all_similar.append(doc)
349
348 - # Step 3: Keyword-based searches
350 + # Step 3: Keyword-based searches with real scores
351 for query in search_queries:
352 if query.strip():
351 - # Fix division by zero: ensure len(search_queries) > 0
352 - queries_count = max(1, len(search_queries)) # Prevent division by zero
353 - keyword_similar = await db.search_similarity_threshold(
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 )
359 - all_similar.extend(keyword_similar)
360 + for doc, score in keyword_results:
361 + doc.metadata['_consolidation_similarity'] = score
362 + all_similar.append(doc)
363
361 - # Step 4: Deduplicate by document ID and store similarity info
362 - seen_ids = set()
363 - unique_similar = []
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:
365 - doc_id = doc.metadata.get('id')
366 - if doc_id and doc_id not in seen_ids:
367 - seen_ids.add(doc_id)
368 - unique_similar.append(doc)
369 -
370 - # Step 5: Calculate similarity scores for replacement validation
371 - # Since FAISS doesn't directly expose similarity scores, use ranking-based estimation
372 - # CRITICAL: All documents must have similarity >= search_threshold since FAISS returned them
373 - # FIXED: Use conservative scoring that keeps all scores in safe consolidation range
374 - similarity_scores = {}
375 - total_docs = len(unique_similar)
376 - search_threshold = self.config.similarity_threshold
377 - safety_threshold = self.config.replace_similarity_threshold
378 -
379 - for i, doc in enumerate(unique_similar):
367 doc_id = doc.metadata.get('id')
368 if doc_id:
382 - # Convert ranking to similarity score with conservative distribution
383 - if total_docs == 1:
384 - ranking_similarity = 1.0 # Single document gets perfect score
385 - else:
386 - # Use conservative scoring: distribute between safety_threshold and 1.0
387 - # This ensures all scores are suitable for consolidation
388 - # First document gets 1.0, last gets safety_threshold (0.9 by default)
389 - ranking_factor = 1.0 - (i / (total_docs - 1))
390 - score_range = 1.0 - safety_threshold # e.g., 1.0 - 0.9 = 0.1
391 - ranking_similarity = safety_threshold + (score_range * ranking_factor)
392 -
393 - # Ensure minimum score is search_threshold for logical consistency
394 - ranking_similarity = max(ranking_similarity, search_threshold)
395 -
396 - similarity_scores[doc_id] = ranking_similarity
397 -
398 - # Step 6: Add similarity score to document metadata for LLM analysis
399 - for doc in unique_similar:
400 - doc_id = doc.metadata.get('id')
401 - estimated_similarity = similarity_scores.get(doc_id, 0.7)
402 - # Store for later validation
403 - doc.metadata['_consolidation_similarity'] = estimated_similarity
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
405 - # Step 7: Limit to max context for LLM
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
@@ -782,7 +761,7 @@ def create_memory_consolidator(agent: Agent, **config_overrides) -> MemoryConsol
761
762 Available configuration options:
763 - similarity_threshold: Discovery threshold for finding related memories (default 0.7)
785 - - replace_similarity_threshold: Safety threshold for REPLACE actions (default 0.9)
764 + - replace_similarity_threshold: Safety threshold for REPLACE actions (default 0.75)
765 - max_similar_memories: Maximum memories to discover (default 10)
766 - max_llm_context_memories: Maximum memories to send to LLM (default 5)
767 - processing_timeout_seconds: Timeout for consolidation processing (default 30)
plugins/_memory/prompts/agent.system.tool.memory.md
+5 -3
@@ -4,14 +4,16 @@ use when durable recall or storage is useful
4 - `memory_save`: args `text`, optional `area` and metadata kwargs
5 - `memory_delete`: arg `ids` comma-separated ids
6 - `memory_forget`: args `query`, optional `threshold`, `filter`
7 +
8 notes:
9 - `threshold` is similarity from `0` to `1`
9 -- `filter` is a python expression over metadata
10 -- verify destructive memory changes if accuracy matters
10 +- `filter` is a metadata expression (e.g. `area=='main'`)
11 +- confirm destructive changes when accuracy matters
12 +
13 example:
14 ~~~json
15 {
14 - "thoughts": ["I should search memory for the relevant prior guidance."],
16 + "thoughts": ["I should search memory for relevant prior guidance."],
17 "headline": "Loading related memories",
18 "tool_name": "memory_load",
19 "tool_args": {