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