Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions memsync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,26 @@ def semantic_dedupe_memory(content: str, config: Config) -> str:
f"Semantic dedupe response too short ({len(cleaned)} chars vs "
f"{len(content)} original) — rejecting to avoid data loss."
)
# Guard: a dedupe may only REMOVE lines, never introduce new ones. Models
# sometimes ignore "return ONLY the cleaned file" and prepend chat commentary
# ("No duplicates were found…") or flag something — which would then be
# written verbatim into GLOBAL_MEMORY.md and loaded into every session. The
# short-response guard above misses this because such pollution makes the
# file LONGER. Fail closed if the output contains any non-blank line that
# was not present verbatim in the input.
original_lines = {ln.strip() for ln in content.splitlines() if ln.strip()}
introduced = [
ln.strip()
for ln in cleaned.splitlines()
if ln.strip() and ln.strip() not in original_lines
]
if introduced:
preview = introduced[0][:80]
raise ValueError(
f"Semantic dedupe introduced {len(introduced)} line(s) absent from the "
f"original (e.g. {preview!r}) — rejecting to avoid writing model "
"commentary into memory."
)
return cleaned


Expand Down
50 changes: 50 additions & 0 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,53 @@ def test_malformed_response_sets_malformed_flag(self):
}):
result = merge_candidates_into_memory("- fact\n", SAMPLE_MEMORY, config)
assert result["malformed"] is True


class TestSemanticDedupeMemory:
@staticmethod
def _llm_result(text: str) -> dict:
return {"text": text, "input_tokens": 5, "output_tokens": 5, "truncated": False}

def test_removes_duplicate_returns_subset(self):
from memsync.sync import semantic_dedupe_memory
config = Config()
# Model correctly drops the second near-duplicate, keeps everything else verbatim.
cleaned = SAMPLE_MEMORY.replace("- Always backup before writing\n", "")
with patch("memsync.llm.call_llm", return_value=self._llm_result(cleaned)):
result = semantic_dedupe_memory(SAMPLE_MEMORY, config)
assert "Never rewrite from scratch" in result
assert "Always backup before writing" not in result

def test_rejects_too_short_response(self):
from memsync.sync import semantic_dedupe_memory
config = Config()
with patch("memsync.llm.call_llm", return_value=self._llm_result("# Global Memory\n")):
with pytest.raises(ValueError, match="too short"):
semantic_dedupe_memory(SAMPLE_MEMORY, config)

def test_rejects_prepended_commentary(self):
# The real failure mode: model prepends chat commentary as file content.
from memsync.sync import semantic_dedupe_memory
config = Config()
polluted = (
"No semantic duplicate bullets were found — the file is returned unchanged.\n\n"
+ SAMPLE_MEMORY
)
with patch("memsync.llm.call_llm", return_value=self._llm_result(polluted)):
with pytest.raises(ValueError, match="introduced"):
semantic_dedupe_memory(SAMPLE_MEMORY, config)

def test_rejects_appended_commentary(self):
from memsync.sync import semantic_dedupe_memory
config = Config()
polluted = SAMPLE_MEMORY + "\nLet me know if you'd like anything else!\n"
with patch("memsync.llm.call_llm", return_value=self._llm_result(polluted)):
with pytest.raises(ValueError, match="introduced"):
semantic_dedupe_memory(SAMPLE_MEMORY, config)

def test_unchanged_file_passes(self):
from memsync.sync import semantic_dedupe_memory
config = Config()
with patch("memsync.llm.call_llm", return_value=self._llm_result(SAMPLE_MEMORY)):
result = semantic_dedupe_memory(SAMPLE_MEMORY, config)
assert "Never rewrite from scratch" in result
Loading