fix: make memory cleanup update stale fragments

Alessandro committed May 11, 2026 at 05:13 UTC 6ba1f30dca2b74622aabfe3e948ba9e5c251ccc8
11 files changed +233 -12
plugins/_memory/helpers/memory.py
+98 -2
@@ -363,11 +363,18 @@ class Memory:
363 )
364
365 async def delete_documents_by_query(
366 - self, query: str, threshold: float, filter: str = ""
366 + self,
367 + query: str,
368 + threshold: float,
369 + filter: str = "",
370 + *,
371 + include_exact: bool = False,
372 + cascade: bool = False,
373 ):
374 k = 100
375 tot = 0
376 removed = []
377 + removed_ids: set[str] = set()
378
379 while True:
380 # Perform similarity search with score
@@ -379,6 +386,7 @@ class Memory:
386 # Extract document IDs and filter based on score
387 # document_ids = [result[0].metadata["id"] for result in docs if result[1] < score_limit]
388 document_ids = [result.metadata["id"] for result in docs]
389 + removed_ids.update(str(doc_id) for doc_id in document_ids)
390
391 # Delete documents with IDs over the threshold score
392 if document_ids:
@@ -392,15 +400,45 @@ class Memory:
400 if len(document_ids) < k:
401 break
402
403 + if include_exact:
404 + exact_docs = self._find_exact_query_docs(query, filter, removed_ids)
405 + if exact_docs:
406 + exact_ids = [doc.metadata["id"] for doc in exact_docs]
407 + await self.db.adelete(ids=exact_ids)
408 + removed += exact_docs
409 + removed_ids.update(str(doc_id) for doc_id in exact_ids)
410 + tot += len(exact_ids)
411 +
412 + if cascade and removed_ids:
413 + related_docs = self._find_related_docs_by_ids(removed_ids)
414 + if related_docs:
415 + related_ids = [doc.metadata["id"] for doc in related_docs]
416 + await self.db.adelete(ids=related_ids)
417 + removed += related_docs
418 + removed_ids.update(str(doc_id) for doc_id in related_ids)
419 + tot += len(related_ids)
420 +
421 if tot:
422 self._save_db() # persist
423 return removed
424
399 - async def delete_documents_by_ids(self, ids: list[str]):
425 + async def delete_documents_by_ids(
426 + self, ids: list[str], *, cascade: bool = False, filter: str = ""
427 + ):
428 # aget_by_ids is not yet implemented in faiss, need to do a workaround
429 rem_docs = await self.db.aget_by_ids(
430 ids
431 ) # existing docs to remove (prevents error)
432 + rem_ids = [doc.metadata["id"] for doc in rem_docs]
433 +
434 + if cascade:
435 + related_docs = self._find_related_docs_by_ids(set(ids) | set(rem_ids))
436 + if related_docs:
437 + existing = {doc.metadata["id"] for doc in rem_docs}
438 + rem_docs.extend(
439 + doc for doc in related_docs if doc.metadata["id"] not in existing
440 + )
441 +
442 if rem_docs:
443 rem_ids = [doc.metadata["id"] for doc in rem_docs] # ids to remove
444 await self.db.adelete(ids=rem_ids)
@@ -445,6 +483,47 @@ class Memory:
483 if not self.db.get_by_ids(doc_id): # check if exists
484 return doc_id
485
486 + def _find_exact_query_docs(
487 + self, query: str, filter: str, skip_ids: set[str]
488 + ) -> list[Document]:
489 + needle = _normalize_memory_match_text(query)
490 + if len(needle) < 3:
491 + return []
492 +
493 + docs: list[Document] = []
494 + comparator = Memory._get_comparator(filter) if filter else None
495 + for doc in self.db.get_all_docs().values():
496 + doc_id = str(doc.metadata.get("id", ""))
497 + if not doc_id or doc_id in skip_ids:
498 + continue
499 + if comparator and not comparator(doc.metadata):
500 + continue
501 + haystack = _normalize_memory_match_text(
502 + f"{doc.page_content}\n{json.dumps(doc.metadata, sort_keys=True, default=str)}"
503 + )
504 + if needle in haystack:
505 + docs.append(doc)
506 + return docs
507 +
508 + def _find_related_docs_by_ids(
509 + self, ids: set[str], filter: str = ""
510 + ) -> list[Document]:
511 + ids = {str(doc_id) for doc_id in ids if str(doc_id)}
512 + if not ids:
513 + return []
514 +
515 + docs: list[Document] = []
516 + comparator = Memory._get_comparator(filter) if filter else None
517 + for doc in self.db.get_all_docs().values():
518 + doc_id = str(doc.metadata.get("id", ""))
519 + if not doc_id or doc_id in ids:
520 + continue
521 + if comparator and not comparator(doc.metadata):
522 + continue
523 + if _metadata_references_any(doc.metadata, ids):
524 + docs.append(doc)
525 + return docs
526 +
527 @staticmethod
528 def _save_db_file(db: MyFaiss, memory_subdir: str):
529 abs_dir = abs_db_dir(memory_subdir)
@@ -547,6 +626,23 @@ def reload():
626 Memory.index = {}
627
628
629 +def _normalize_memory_match_text(value: str) -> str:
630 + return " ".join(str(value or "").casefold().split())
631 +
632 +
633 +def _metadata_references_any(value: Any, ids: set[str]) -> bool:
634 + if isinstance(value, dict):
635 + return any(_metadata_references_any(item, ids) for item in value.values())
636 + if isinstance(value, (list, tuple, set)):
637 + return any(_metadata_references_any(item, ids) for item in value)
638 + text = str(value or "").strip()
639 + if not text:
640 + return False
641 + if text in ids:
642 + return True
643 + return any(doc_id in text.split(",") for doc_id in ids)
644 +
645 +
646 def abs_db_dir(memory_subdir: str) -> str:
647 # patch for projects, this way we don't need to re-work the structure of memory subdirs
648 if memory_subdir.startswith("projects/"):
plugins/_memory/helpers/memory_consolidation.py
+4 -2
@@ -728,9 +728,11 @@ class MemoryConsolidator:
728 updated_count += 1
729 updated_ids.append(new_id)
730
731 - # Step 2: Insert additional new memory if provided
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
733 - if result.new_memory_content:
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,
plugins/_memory/prompts/agent.system.memories.md
+2 -1
@@ -1,5 +1,6 @@
1 # Memories on the topic
2 - following are memories about current topic
3 - do not overly rely on them they might not be relevant
4 +- if memories conflict, prefer the newest/current fact and ignore superseded older fragments
5
5 -{{memories}}
\ No newline at end of file
6 +{{memories}}
plugins/_memory/prompts/agent.system.tool.memory.md
+4
@@ -9,6 +9,10 @@ notes:
9 - `threshold` is similarity from `0` to `1`
10 - `filter` is a metadata expression (e.g. `area=='main'`)
11 - confirm destructive changes when accuracy matters
12 +- when the user updates a durable fact/preference, load related memories first, forget/delete superseded versions, then save one complete current version
13 +- do not append a second memory for the same mutable subject when the new statement replaces the old one
14 +- `memory_forget` also cleans exact matches and derived fragment/solution records related to removed memories
15 +- use `memory_save` for stable current facts, not short-lived test markers, greetings, or one-off conversation events
16
17 example:
18 ~~~json
plugins/_memory/prompts/memory.consolidation.sys.md
+13 -4
@@ -11,6 +11,7 @@ Analyze a new memory alongside existing similar memories and determine whether t
11 - **keep_separate** if memories serve different purposes
12 - **skip** consolidation if no action is beneficial
13
14 +Default bias: for mutable user preferences, project state, configuration choices, names, locations, active tasks, or "current" facts about the same subject, prefer **update** or **replace** over appending another separate memory.
15
16 ## Consolidation Analysis Guidelines
17
@@ -22,14 +23,16 @@ Analyze a new memory alongside existing similar memories and determine whether t
23
24 ### 1. Temporal Intelligence
25 - **Newer information** generally supersedes older information
25 -- **Preserve historical context** when consolidating - don't lose important chronological details
26 -- **Consider recency** - more recent memories may be more relevant
26 +- **Preserve historical context** only when the user explicitly needs history or an audit trail
27 +- **Do not keep old preferences as equally important memories** when the new memory clearly gives the current state
28 +- **Consider recency** - more recent memories are usually more relevant for mutable facts
29
30 ### 2. Content Relationships
31 - **Complementary information** should be merged into comprehensive memories
32 - **Contradictory information** requires careful analysis of which is more accurate/current
33 - **Duplicate content** should be consolidated to eliminate redundancy
34 - **Distinct but related topics** may be better kept separate
35 +- **Same subject, changed value** should usually be update or replace, not keep_separate
36
37 ### 3. Quality Assessment
38 - **More detailed/complete** information should be preserved
@@ -79,8 +82,8 @@ Provide your analysis as a JSON object with this exact structure:
82
83 - **merge**: Combine multiple memories into one comprehensive memory, removing originals
84 - **replace**: Replace outdated, incorrect, or superseded memories with new version, preserving important metadata. Use when new information directly contradicts or makes old information obsolete.
82 -- **keep_separate**: New memory addresses different aspects, keep all memories separate
83 -- **update**: Enhance existing memory with additional details from new memory
85 +- **keep_separate**: New memory addresses a genuinely different subject or stable historical event, keep all memories separate
86 +- **update**: Repopulate an existing memory for the same subject with the latest complete current version; do not insert an additional memory when the updated memory is sufficient
87 - **skip**: No consolidation needed, use simple insertion for new memory
88
89 ## Example Consolidation Scenarios
@@ -95,6 +98,11 @@ Provide your analysis as a JSON object with this exact structure:
98 **Existing**: "User API endpoint is /api/users for getting user data"
99 **Action**: replace → Update with new endpoint, note the change in historical_notes
100
101 +### Scenario 2b: Update Current Preference
102 +**New**: "User now prefers concise technical answers with examples"
103 +**Existing**: "User prefers long exploratory answers"
104 +**Action**: update -> Rewrite the existing user-preference memory to the new current preference. Do not keep both as equally relevant memories.
105 +
106 **REPLACE Criteria**: Use replace when:
107 - **High similarity score** (>0.9) indicates very similar content
108 - New information directly contradicts existing information
@@ -116,6 +124,7 @@ Provide your analysis as a JSON object with this exact structure:
124 3. **Maintain Context**: Keep temporal and source information where relevant
125 4. **Enhance Searchability**: Use consolidation to improve future memory retrieval
126 5. **Reduce Redundancy**: Eliminate unnecessary duplication while preserving nuance
127 +6. **Keep Current Facts Current**: For mutable facts, the final memory should represent the latest usable state, not a human-like archive of every old version
128
129 ## Instructions
130
plugins/_memory/prompts/memory.memories_filter.sys.md
+4 -1
@@ -13,6 +13,8 @@
13 - Focus on USER MESSAGE if provided, use HISTORY for context
14 - Keep in mind that these memories should be helpful for continuing the conversation and solving problems by AI
15 - Consider if each memory holds real information value for the context or not
16 +- If multiple memories conflict about the same mutable user/project fact, include only the newest/current one when it is identifiable
17 +- Exclude superseded, historical, duplicate, or low-detail fragments when a more complete current memory is available
18
19 # Include only when:
20 - Memory is relevant to the current situation
@@ -22,6 +24,7 @@
24 - Short vague texts like "Pet inquiry" or "Programming skills" with no more detail
25 - Common conversation patterns like greetings
26 - Memories that hold no information value
27 +- Older conflicting memories for the same preference or project state when a newer/current memory is available
28
29 # Example output
30 ```json
@@ -32,4 +35,4 @@
35 > "User has greeted me" (no information value)
36 > "Hello world program" (just title, no details, no context, irrelevant by itself)
37 > "Today is Monday" (just date, information obsolete, not helpful)
35 -> "Memory search" (just title, irrelevant by itself)
\ No newline at end of file
38 +> "Memory search" (just title, irrelevant by itself)
plugins/_memory/prompts/memory.memories_sum.sys.md
+5
@@ -30,6 +30,9 @@
30 - Do not break information related to the same subject into multiple memories, keep them as one text
31 - If there are multiple facts related to the same subject, merge them into one more detailed memory instead
32 - Example: Instead of three memories "User's dog is Max", "Max is 6 years old", "Max is white and brown", create one memory "User's dog is Max, 6 years old, white and brown."
33 +- If the history changes or corrects a previously stated fact, output only the new complete current fact; do not output both old and new versions
34 +- Prefer a single durable profile-style sentence for mutable user/project preferences, such as "User currently prefers..." or "Project currently uses..."
35 +- Do not memorize temporary test markers, temporary behavior checks, or cleanup-only facts
36
37 # Correct examples of data worth memorizing with (explanation)
38 > User's name is John Doe (name is important)
@@ -45,6 +48,8 @@
48 > Today is Monday (just date, no value in this information)
49 > Market inquiry (just a topic without detail)
50 > RAM Status (just a topic without detail)
51 +> User used to prefer X before changing to Y (historical preference is usually not useful; memorize the current preference only)
52 +> Temporary marker ABC123 was used in a memory test (test residue, not useful)
53
54
55 # Further WRONG examples
plugins/_memory/tools/memory_delete.py
+1 -1
@@ -8,7 +8,7 @@ class MemoryDelete(Tool):
8 async def execute(self, ids="", **kwargs):
9 db = await Memory.get(self.agent)
10 ids = [id.strip() for id in ids.split(",") if id.strip()]
11 - dels = await db.delete_documents_by_ids(ids=ids)
11 + dels = await db.delete_documents_by_ids(ids=ids, cascade=True)
12
13 result = self.agent.read_prompt("fw.memories_deleted.md", memory_count=len(dels))
14 return Response(message=result, break_loop=False)
plugins/_memory/tools/memory_forget.py
+7 -1
@@ -8,7 +8,13 @@ class MemoryForget(Tool):
8
9 async def execute(self, query="", threshold=DEFAULT_THRESHOLD, filter="", **kwargs):
10 db = await Memory.get(self.agent)
11 - dels = await db.delete_documents_by_query(query=query, threshold=threshold, filter=filter)
11 + dels = await db.delete_documents_by_query(
12 + query=query,
13 + threshold=threshold,
14 + filter=filter,
15 + include_exact=True,
16 + cascade=True,
17 + )
18
19 result = self.agent.read_prompt("fw.memories_deleted.md", memory_count=len(dels))
20 return Response(message=result, break_loop=False)
tests/test_memory_cleanup.py new
+93
@@ -0,0 +1,93 @@
1 +from __future__ import annotations
2 +
3 +import sys
4 +import asyncio
5 +from pathlib import Path
6 +
7 +from langchain_core.documents import Document
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +if str(PROJECT_ROOT) not in sys.path:
11 + sys.path.insert(0, str(PROJECT_ROOT))
12 +
13 +from plugins._memory.helpers.memory import Memory
14 +
15 +
16 +class FakeFaiss:
17 + def __init__(self, docs: list[Document]):
18 + self.docs = {doc.metadata["id"]: doc for doc in docs}
19 + self.deleted: list[str] = []
20 +
21 + async def asearch(self, *_args, **_kwargs):
22 + return []
23 +
24 + async def adelete(self, ids):
25 + for doc_id in ids:
26 + self.deleted.append(doc_id)
27 + self.docs.pop(doc_id, None)
28 +
29 + async def aget_by_ids(self, ids):
30 + return [self.docs[doc_id] for doc_id in ids if doc_id in self.docs]
31 +
32 + def get_all_docs(self):
33 + return self.docs
34 +
35 + def get_by_ids(self, ids):
36 + return [self.docs[doc_id] for doc_id in ids if doc_id in self.docs]
37 +
38 +
39 +def test_memory_forget_removes_exact_matches_and_derived_fragments():
40 + main = Document(
41 + page_content="User currently prefers memory cleanup token banana-397.",
42 + metadata={"id": "main-1", "area": "main"},
43 + )
44 + fragment = Document(
45 + page_content="Derived note from old preference.",
46 + metadata={
47 + "id": "fragment-1",
48 + "area": "fragments",
49 + "consolidated_from": ["main-1"],
50 + },
51 + )
52 + unrelated = Document(
53 + page_content="Unrelated memory about project setup.",
54 + metadata={"id": "other-1", "area": "main"},
55 + )
56 + fake_db = FakeFaiss([main, fragment, unrelated])
57 + memory = Memory(fake_db, memory_subdir="test")
58 + memory._save_db = lambda: None
59 +
60 + removed = asyncio.run(
61 + memory.delete_documents_by_query(
62 + query="banana-397",
63 + threshold=0.99,
64 + include_exact=True,
65 + cascade=True,
66 + )
67 + )
68 +
69 + assert {doc.metadata["id"] for doc in removed} == {"main-1", "fragment-1"}
70 + assert fake_db.deleted == ["main-1", "fragment-1"]
71 + assert set(fake_db.docs) == {"other-1"}
72 +
73 +
74 +def test_memory_delete_cascades_even_when_original_id_is_already_missing():
75 + replacement = Document(
76 + page_content="User currently prefers concise technical answers.",
77 + metadata={
78 + "id": "replacement-1",
79 + "area": "main",
80 + "updated_from": "old-pref-1",
81 + },
82 + )
83 + fake_db = FakeFaiss([replacement])
84 + memory = Memory(fake_db, memory_subdir="test")
85 + memory._save_db = lambda: None
86 +
87 + removed = asyncio.run(
88 + memory.delete_documents_by_ids(["old-pref-1"], cascade=True)
89 + )
90 +
91 + assert [doc.metadata["id"] for doc in removed] == ["replacement-1"]
92 + assert fake_db.deleted == ["replacement-1"]
93 + assert fake_db.docs == {}
tests/test_tool_action_contracts.py
+2
@@ -173,6 +173,8 @@ def test_memory_forget_tool_imports_plugin_memory_load(monkeypatch):
173 "query": "codex memory forget token",
174 "threshold": 0.99,
175 "filter": "area=='codex_sweep'",
176 + "include_exact": True,
177 + "cascade": True,
178 }
179 ]
180