diff --git a/packages/nooa-memory/src/nooa_memory/store.py b/packages/nooa-memory/src/nooa_memory/store.py index e3cfd1e95..526ae38a1 100644 --- a/packages/nooa-memory/src/nooa_memory/store.py +++ b/packages/nooa-memory/src/nooa_memory/store.py @@ -341,8 +341,9 @@ def resolve_id(self, id_or_prefix: str) -> str | None: return str(row["id"]) if len(id_or_prefix) < 6: return None + prefix = id_or_prefix.replace("!", "!!").replace("%", "!%").replace("_", "!_") rows = self._conn.execute( - "SELECT id FROM memories WHERE id LIKE ? LIMIT 2", (id_or_prefix + "%",) + "SELECT id FROM memories WHERE id LIKE ? ESCAPE '!' LIMIT 2", (prefix + "%",) ).fetchall() if len(rows) > 1: raise ValueError(f"memory id prefix {id_or_prefix!r} is ambiguous") diff --git a/packages/nooa-memory/tests/memory/test_memory_contract.py b/packages/nooa-memory/tests/memory/test_memory_contract.py index 42f3a41d0..10f9798d3 100644 --- a/packages/nooa-memory/tests/memory/test_memory_contract.py +++ b/packages/nooa-memory/tests/memory/test_memory_contract.py @@ -136,11 +136,27 @@ def test_ambiguous_prefix_raises(agent): def test_too_short_prefix_is_not_found(agent): + """A prefix shorter than the public minimum cannot select a stored memory.""" _install(agent) agent.remember("something", type="info") assert agent.forget("abc") is False # <6 chars never prefix-matches +@pytest.mark.parametrize("prefix", ["%%%%%%", "______"]) +def test_wildcard_prefix_cannot_update_or_forget_memory(agent, prefix): + """Wildcard-looking input leaves unrelated memory content and archive state intact.""" + mgr = _install(agent) + try: + mid = agent.remember("original fact", type="info") + assert agent.update_memory(prefix, content="wrong replacement") is False + assert agent.forget(prefix) is False + stored = mgr.store.get(mid) + assert stored.content == "original fact" + assert stored.archived is False + finally: + mgr.uninstall() + + # -------------------------------------------------------------------------- # raise-don't-recover at the tool boundary # -------------------------------------------------------------------------- diff --git a/packages/nooa-memory/tests/memory/test_memory_store.py b/packages/nooa-memory/tests/memory/test_memory_store.py index 73d82d14c..607f7b6be 100644 --- a/packages/nooa-memory/tests/memory/test_memory_store.py +++ b/packages/nooa-memory/tests/memory/test_memory_store.py @@ -34,6 +34,7 @@ def _add(store, emb, content, **kw): def test_add_get_roundtrip(store, emb): + """Reading a newly stored memory preserves its content and importance.""" m = _add(store, emb, "deploy uses make ship", type=MemoryType.SKILL, importance=7.0) got = store.get(m.id) assert got is not None @@ -42,7 +43,25 @@ def test_add_get_roundtrip(store, emb): assert got.importance == 7.0 +@pytest.mark.parametrize("prefix", ["%%%%%%", "______", "abcde%", "abcde_"]) +def test_resolve_id_does_not_expand_sql_wildcards(store, prefix): + """SQL wildcard characters cannot broaden an otherwise nonmatching ID prefix.""" + store.add(Memory(id="abcdef123456", content="original")) + assert store.resolve_id(prefix) is None + + +@pytest.mark.parametrize("character", ["%", "_", "!"]) +def test_resolve_id_matches_literal_special_characters(store, character): + """Literal percent, underscore, and escape characters remain valid ID text.""" + mid = f"abcde{character}123456" + store.add(Memory(id=mid, content="literal id")) + store.add(Memory(id="abcdef123456", content="different id")) + assert store.resolve_id(mid) == mid + assert store.resolve_id(mid[:6]) == mid + + def test_save_persists_mutation(store, emb): + """Saving an existing memory persists its updated access metadata.""" m = _add(store, emb, "fact") m.touch() m.importance = 9.0