diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 474c8c5c..df9db73a 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -94,9 +94,23 @@ async def init_search_index(self): raise e # Fail fast: create vector tables at startup so missing sqlite-vec - # or embedding provider errors surface immediately + # or embedding provider errors surface immediately. + # Trigger: the runtime semantic stack (sqlite-vec extension or embedding + # provider) is unavailable at startup. + # Why: failing the whole MCP boot for a search-only feature blocks + # Claude Desktop's handshake (#711). Keyword-only search is a + # reasonable fallback while the user resolves the dependency. + # Outcome: log the cause, mark this repository as semantic-disabled so + # downstream calls short-circuit cleanly, and let init complete. if self._semantic_enabled: - await self._ensure_vector_tables() + try: + await self._ensure_vector_tables() + except SemanticDependenciesMissingError as exc: + logger.warning( + f"Semantic search disabled: {exc}. " + "Falling back to keyword-only search." + ) + self._semantic_enabled = False # ------------------------------------------------------------------ # FTS5 query preparation (backend-specific) @@ -374,6 +388,25 @@ async def _ensure_sqlite_vec_loaded(self, session) -> None: async_connection = await session.connection() raw_connection = await async_connection.get_raw_connection() driver_connection = raw_connection.driver_connection + + # Trigger: the underlying CPython was built without sqlite extension support. + # Why: python.org's macOS installer ships a stripped sqlite3 module with no + # enable_load_extension; when uvx happens to pick that interpreter (#711), + # the AttributeError surfaces here and previously crashed startup before + # Claude Desktop could complete its MCP handshake. + # Outcome: convert to SemanticDependenciesMissingError so the init-time + # handler can degrade gracefully to keyword search instead of dying. + if not hasattr(driver_connection, "enable_load_extension"): + raise SemanticDependenciesMissingError( + "This Python build does not support SQLite extension loading " + "(no enable_load_extension on sqlite3.Connection). " + "Common cause: python.org Python on macOS. " + "Reinstall basic-memory under a Python that ships extension " + "support (uv-managed CPython, Homebrew Python, or the official " + "Docker image), or set semantic_search_enabled=false in config " + "to silence this and use keyword-only search." + ) + await driver_connection.enable_load_extension(True) await driver_connection.load_extension(sqlite_vec.loadable_path()) await driver_connection.enable_load_extension(False) diff --git a/tests/repository/test_search_repository.py b/tests/repository/test_search_repository.py index 0ff6b270..27e8b8da 100644 --- a/tests/repository/test_search_repository.py +++ b/tests/repository/test_search_repository.py @@ -107,6 +107,78 @@ async def test_init_search_index(search_repository, app_config): assert table_name == "search_index" +@pytest.mark.asyncio +async def test_init_search_index_degrades_when_extension_loading_unavailable( + search_repository, monkeypatch +): + """Regression for #711: when sqlite-vec cannot be loaded (e.g. python.org Python + 3.12 ships sqlite3 without enable_load_extension), init must NOT crash. It should + log a warning, mark the repository as semantic-disabled, and let the rest of the + process come up so Claude Desktop's MCP handshake completes.""" + if is_postgres_backend(search_repository): + pytest.skip("python.org enable_load_extension issue is SQLite-specific") + + from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError + + # Force the codepath even if semantic_search wasn't enabled by default. + search_repository._semantic_enabled = True + + async def _raise_missing(): + raise SemanticDependenciesMissingError("simulated: enable_load_extension missing") + + monkeypatch.setattr(search_repository, "_ensure_vector_tables", _raise_missing) + + # Must not raise — startup needs to complete even when the semantic stack is dead. + await search_repository.init_search_index() + + assert search_repository._semantic_enabled is False, ( + "Repository should mark itself semantic-disabled after a missing-deps error " + "so downstream calls short-circuit cleanly instead of re-attempting load." + ) + + +@pytest.mark.asyncio +async def test_ensure_sqlite_vec_loaded_raises_typed_error_without_extension_support( + search_repository, monkeypatch +): + """Regression for #711: AttributeError from a sqlite3.Connection that lacks + enable_load_extension must surface as SemanticDependenciesMissingError so the + init-time handler can degrade. Otherwise the AttributeError bubbles through and + crashes startup before Claude Desktop completes its handshake.""" + if is_postgres_backend(search_repository): + pytest.skip("enable_load_extension is SQLite-specific") + + from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError + from sqlalchemy.exc import OperationalError as SAOperationalError + + # Stub session that always reports vec missing on probe, then yields a connection + # whose driver_connection has no enable_load_extension attribute (mirroring the + # python.org sqlite3 build). + class _StubDriverConnection: + # Deliberately omit enable_load_extension to mimic the python.org build. + pass + + class _StubRawConnection: + driver_connection = _StubDriverConnection() + + class _StubAsyncConnection: + async def get_raw_connection(self): + return _StubRawConnection() + + class _StubSession: + async def execute(self, _stmt): + # First (and any) probe call reports vec missing. + raise SAOperationalError("SELECT vec_version()", {}, Exception("no vec")) + + async def connection(self): + return _StubAsyncConnection() + + with pytest.raises(SemanticDependenciesMissingError) as exc_info: + await search_repository._ensure_sqlite_vec_loaded(_StubSession()) + + assert "enable_load_extension" in str(exc_info.value) + + @pytest.mark.asyncio async def test_init_search_index_preserves_data(search_repository, search_entity): """Regression test: calling init_search_index() twice should preserve indexed data.