diff --git a/packages/nooa-memory/src/nooa_memory/manager.py b/packages/nooa-memory/src/nooa_memory/manager.py index 4573a18e8..c8fe3e375 100644 --- a/packages/nooa-memory/src/nooa_memory/manager.py +++ b/packages/nooa-memory/src/nooa_memory/manager.py @@ -403,14 +403,16 @@ def update( status: str | None = None, references: list[str] | None = None, ) -> bool: - """Refine an existing memory in place (re-embeds if content changed).""" + """Refine a memory, refreshing derived metadata and changed embedding input.""" m = self._find(memory_id) if m is None: return False self._assert_writable(m) - content_changed = content is not None and content != m.content - if content is not None: - m.content = content + embedding_before = m.embedding_text() + if content is not None and content != m.content: + # Reuse the schema's derivation rules without losing record history. + data = m.model_dump(exclude={"size_chars", "token_len", "sentence_count"}) + m = Memory.model_validate({**data, "content": content}) if importance is not None: m.importance = max(0.0, min(10.0, importance)) if type is not None: @@ -434,7 +436,7 @@ def update( reinforce=False, cap=self.config.observability.access_log_cap, ) - if content_changed: + if m.embedding_text() != embedding_before: self.store.add(m, self.embedder.embed(m.embedding_text())) # re-embed + replace else: self.store.save(m) diff --git a/packages/nooa-memory/tests/memory/test_memory_manager.py b/packages/nooa-memory/tests/memory/test_memory_manager.py index e91c54add..9f271c1a5 100644 --- a/packages/nooa-memory/tests/memory/test_memory_manager.py +++ b/packages/nooa-memory/tests/memory/test_memory_manager.py @@ -7,9 +7,12 @@ """ from pathlib import Path +from unittest.mock import patch +import numpy as np import pytest from nooa_memory import ( + Memory, MemoryConfig, MemoryManager, MemoryToolsMixin, @@ -146,6 +149,7 @@ def test_remember_and_recall_via_tools(agent): def test_remember_dedups_on_write(agent): + """Remembering an existing fact reinforces its record instead of adding a duplicate.""" mgr = _install(agent) id1 = agent.remember("identical fact about shipping releases", type="info") id2 = agent.remember("identical fact about shipping releases", type="info") @@ -154,6 +158,46 @@ def test_remember_dedups_on_write(agent): assert mgr.store.get(id1).reinforcement_count >= 1 +@pytest.mark.parametrize("content", ["A longer sentence. Another sentence! A third?", "Hi.", ""]) +def test_update_refreshes_content_metadata(agent, content): + """Content edits recalculate size metadata while preserving identity and history.""" + mgr = _install(agent) + try: + mid = mgr.remember("Original content to be revised.", tags=["original"]) + before = mgr.store.get(mid) + assert mgr.update(mid, content=content) + after = mgr.store.get(mid) + expected = Memory(content=content) + assert after.content == content + for field in ("size_chars", "token_len", "sentence_count"): + assert getattr(after, field) == getattr(expected, field) + for field in ("id", "owner", "created_at", "tags"): + assert getattr(after, field) == getattr(before, field) + assert after.access_log[:-1] == before.access_log + assert after.reinforcement_count == before.reinforcement_count + 1 + finally: + mgr.uninstall() + + +@pytest.mark.parametrize("tags", [["aardvark", "zebra"], []]) +def test_update_reembeds_changed_tags_only_when_needed(agent, tags): + """Changed tags refresh the embedding, but unchanged embedding input is reused.""" + mgr = _install(agent) + try: + mid = mgr.remember("A stored fact.", tags=["original"]) + original = mgr.store.get_embedding(mid) + assert mgr.update(mid, tags=tags) + current = mgr.store.get(mid) + expected = mgr.embedder.embed(current.embedding_text()) + assert not np.allclose(original, expected) + np.testing.assert_allclose(mgr.store.get_embedding(mid), expected) + with patch.object(mgr.embedder, "embed", wraps=mgr.embedder.embed) as embed: + assert mgr.update(mid, tags=tags, importance=9) + embed.assert_not_called() + finally: + mgr.uninstall() + + # -------------------------------------------------------------------------- # spontaneous association (pre-turn injection) # --------------------------------------------------------------------------