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
37 changes: 35 additions & 2 deletions src/basic_memory/repository/sqlite_search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions tests/repository/test_search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading