diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 77d9dcc..d3fb32c 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -94,6 +94,8 @@ for session in sessions: if not session.messages: continue + if session.user_id is None: + continue # skip legacy sessions — no multi-user memory for unowned sessions if session.last_active >= cutoff: continue # still active extracted_at = await memory.get_extracted_at(session.id) diff --git a/navi/memory/_facts.py b/navi/memory/_facts.py index e94a847..b75bea8 100644 --- a/navi/memory/_facts.py +++ b/navi/memory/_facts.py @@ -88,9 +88,6 @@ ) async def search_facts(self, query: str, user_id: str | None = None, limit: int = 15) -> list[dict]: - user_clause = "AND user_id IS NULL" if user_id is None else "AND user_id = $3" - user_param = () if user_id is None else (user_id,) - # 1. Try vector search if pgvector + embedding backend are available if self._embedding_backend and await self._has_pgvector(): query_embedding = await self._generate_embedding(query) diff --git a/navi/memory/_summary.py b/navi/memory/_summary.py index 716306a..467ab0a 100644 --- a/navi/memory/_summary.py +++ b/navi/memory/_summary.py @@ -1,5 +1,6 @@ """Conversation summary persistence — single-row table.""" +import zlib from datetime import datetime, timezone @@ -12,7 +13,8 @@ if user_id is None: return 1 # Deterministic, non-overlapping with legacy id=1 - return (abs(hash(user_id)) % 2147483647) + 2 + # zlib.crc32 is stable across process restarts (unlike Python hash()). + return (abs(zlib.crc32(user_id.encode())) % 2147483646) + 2 class SummaryMixin: diff --git a/navi/memory/extractor.py b/navi/memory/extractor.py index bebcb80..f4d135f 100644 --- a/navi/memory/extractor.py +++ b/navi/memory/extractor.py @@ -75,14 +75,19 @@ """ Extract facts from a session and update the memory summary. Safe to call multiple times — already-extracted sessions produce no duplicates. + Legacy sessions (user_id=None) are skipped — no multi-user memory for unowned sessions. """ + user_id = getattr(session, "user_id", None) + if user_id is None: + return + facts_added = await _extract_facts(session, llm, model, memory_store) log.info("memory.extracted", session_id=session.id, facts_added=facts_added) await memory_store.mark_session_extracted(session.id) if facts_added > 0: - await _regenerate_summary(llm, model, memory_store, user_id=session.user_id) + await _regenerate_summary(llm, model, memory_store, user_id=user_id) _MAX_TOOL_RESULT_LEN = 500 diff --git a/tests/unit/memory/test_extractor.py b/tests/unit/memory/test_extractor.py index 78caa73..dcb0b82 100644 --- a/tests/unit/memory/test_extractor.py +++ b/tests/unit/memory/test_extractor.py @@ -115,7 +115,7 @@ conn = FakeConnection() conn.enqueue("OK") # mark_session_extracted store = make_store_with_pool(conn) - session = FakeSession([Message(role="user", content="hi")]) + session = FakeSession([Message(role="user", content="hi")], user_id="user-1") await extract_and_update(session, backend, "test-model", store) assert any("session_memory_state" in c[1] for c in conn.calls) @@ -134,7 +134,7 @@ ]) conn.enqueue("OK") # set_summary store = make_store_with_pool(conn) - session = FakeSession([Message(role="user", content="My name is Eugene")]) + session = FakeSession([Message(role="user", content="My name is Eugene")], user_id="user-1") await extract_and_update(session, backend, "test-model", store) assert any("memory_summary" in c[1] for c in conn.calls) @@ -143,7 +143,16 @@ conn = FakeConnection() conn.enqueue("OK") # mark_session_extracted store = make_store_with_pool(conn) - session = FakeSession([Message(role="user", content="hi")]) + session = FakeSession([Message(role="user", content="hi")], user_id="user-1") await extract_and_update(session, backend, "test-model", store) # Should NOT call get_all_facts or set_summary assert not any("memory_summary" in c[1] for c in conn.calls) + + async def test_skips_legacy_sessions(self): + backend = FakeLLMBackend(responses=["[]"]) + conn = FakeConnection() + store = make_store_with_pool(conn) + session = FakeSession([Message(role="user", content="hi")], user_id=None) + await extract_and_update(session, backend, "test-model", store) + # No DB calls for legacy (user_id=None) sessions + assert len(conn.calls) == 0