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
11 changes: 8 additions & 3 deletions nerve/memory/memu_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,10 +977,15 @@ def _fast_vector_search(self, query_vec, top_k, where=None, *, ranking="similari
_original_delete_item = SQLiteMemoryItemRepo.delete_item
_original_clear_items = SQLiteMemoryItemRepo.clear_items

def _indexed_update_item(self, item_id, *args, **kwargs):
result = _original_update_item(self, item_id, *args, **kwargs)
def _indexed_update_item(self, *args, **kwargs):
# Forward args verbatim. memu-py 1.4.0 makes update_item's
# parameters (including item_id) keyword-only, so forwarding
# item_id positionally raised "takes 1 positional argument but
# 2 were given" and silently failed every memory_update.
result = _original_update_item(self, *args, **kwargs)
item_id = kwargs.get("item_id", args[0] if args else None)
idx = _vec_index_note(self)
if idx is not None:
if idx is not None and item_id is not None:
cached = self.items.get(item_id)
if cached is not None and cached.embedding is not None:
idx.upsert(item_id, str(cached.memory_type), cached.embedding)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_memu_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,3 +879,76 @@ def test_wal_conversion(self, tmp_path):

def test_missing_db_does_not_raise(self, tmp_path):
MemUBridge._setup_sqlite_pragmas(f"sqlite:///{tmp_path}/nope/missing.sqlite")


# ---------------------------------------------------------------------------


class TestIndexedUpdateItemForwarding:
"""Regression: the _indexed_update_item monkeypatch must forward item_id
by keyword.

memu-py 1.4.0's ``SQLiteMemoryItemRepo.update_item`` is keyword-only
(``def update_item(self, *, item_id, ...)``). The vector-index wrapper used
to forward ``item_id`` positionally, which raised "takes 1 positional
argument but 2 positional arguments (and 3 keyword-only arguments) were
given" — silently turning every ``memory_update`` tool call into
"Failed to update memory". ``delete_item(self, item_id)`` is NOT keyword
only, which is why deletes kept working and masked the bug.
"""

def test_update_item_is_keyword_only_in_memu(self):
import inspect
import memu.app.service # noqa: F401 — initialize package graph first
from memu.database.sqlite.repositories.memory_item_repo import (
SQLiteMemoryItemRepo as Repo,
)

kind = inspect.signature(Repo.update_item).parameters["item_id"].kind
assert kind is inspect.Parameter.KEYWORD_ONLY, (
"memu update_item contract changed — revisit _indexed_update_item"
)

def test_wrapper_forwards_item_id_as_keyword(self):
import memu.app.service # noqa: F401 — initialize package graph first
from memu.database.sqlite.repositories.memory_item_repo import (
SQLiteMemoryItemRepo as Repo,
)

calls: list[str] = []

def spy_update(self, *, item_id, memory_type=None, summary=None,
embedding=None, extra=None, tool_record=None):
calls.append(item_id)
return "spy-result"

# Snapshot the item-repo methods _patch_sqlite_bugs() reassigns so the
# test restores global state and does not leak into other tests.
names = (
"update_item", "delete_item", "clear_items", "list_items",
"create_item", "create_item_reinforce", "vector_search_items",
)
saved = {n: Repo.__dict__.get(n) for n in names}

Repo.update_item = spy_update
try:
# Returns None on success (the body falls through); the observable
# effect is that update_item gets wrapped in front of our spy.
MemUBridge._patch_sqlite_bugs()
assert Repo.update_item is not spy_update

stub = object.__new__(Repo) # no _nerve_vec_index → index hook skipped
# Exactly how memu's crud layer calls it (all keyword) — used to raise.
result = Repo.update_item(
stub, item_id="mem-123", memory_type=None,
summary="updated", embedding=None,
)
assert result == "spy-result"
assert calls == ["mem-123"]
finally:
for name, fn in saved.items():
if fn is None:
if name in Repo.__dict__:
delattr(Repo, name)
else:
setattr(Repo, name, fn)