fix: implement project-specific sync status checks for MCP tools by phernandez · Pull Request #183 · basicmachines-co/basic-memory · GitHub
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
9 changes: 6 additions & 3 deletions src/basic_memory/mcp/tools/build_context.py
9 changes: 6 additions & 3 deletions src/basic_memory/mcp/tools/read_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ async def read_note(
read_note("Meeting Notes", project="work-project")
"""

# Get the active project first to check project-specific sync status
active_project = get_active_project(project)

# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status

migration_status = await wait_for_migration_or_return_status(timeout=5.0)
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."

active_project = get_active_project(project)
project_url = active_project.project_url

# Get the file via REST API - first try direct permalink lookup
Expand Down
31 changes: 27 additions & 4 deletions src/basic_memory/mcp/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,16 @@ def check_migration_status() -> Optional[str]:
return None


async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
async def wait_for_migration_or_return_status(
timeout: float = 5.0, project_name: Optional[str] = None
) -> Optional[str]:
"""Wait briefly for sync/migration to complete, or return status message.

Args:
timeout: Maximum time to wait for sync completion
project_name: Optional project name to check specific project status.
If provided, only checks that project's readiness.
If None, uses global status check (legacy behavior).

Returns:
Status message if sync is still in progress, None if ready
Expand All @@ -538,18 +543,36 @@ async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[
from basic_memory.services.sync_status_service import sync_status_tracker
import asyncio

if sync_status_tracker.is_ready:
# Check if we should use project-specific or global status
def is_ready() -> bool:
if project_name:
return sync_status_tracker.is_project_ready(project_name)
return sync_status_tracker.is_ready

if is_ready():
return None

# Wait briefly for sync to complete
start_time = asyncio.get_event_loop().time()
while (asyncio.get_event_loop().time() - start_time) < timeout:
if sync_status_tracker.is_ready:
if is_ready():
return None
await asyncio.sleep(0.1) # Check every 100ms

# Still not ready after timeout
return sync_status_tracker.get_summary()
if project_name:
# For project-specific checks, get project status details
project_status = sync_status_tracker.get_project_status(project_name)
if project_status and project_status.status.value == "failed":
error_msg = project_status.error or "Unknown sync error"
return f"❌ Sync failed for project '{project_name}': {error_msg}"
elif project_status:
return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
else:
return f"⚠️ Project '{project_name}' status unknown"
else:
# Fall back to global summary for legacy calls
return sync_status_tracker.get_summary()
except Exception: # pragma: no cover
# If there's any error, assume ready
return None
8 changes: 6 additions & 2 deletions src/basic_memory/mcp/tools/write_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,15 @@ async def write_note(
"""
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")

# Get the active project first to check project-specific sync status
active_project = get_active_project(project)

# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status

migration_status = await wait_for_migration_or_return_status(timeout=5.0)
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."

Expand All @@ -91,7 +96,6 @@ async def write_note(
content=content,
entity_metadata=metadata,
)
active_project = get_active_project(project)
project_url = active_project.project_url

# Create or update via knowledge API
Expand Down
86 changes: 57 additions & 29 deletions src/basic_memory/repository/search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,64 +123,64 @@ async def init_search_index(self):

def _prepare_boolean_query(self, query: str) -> str:
"""Prepare a Boolean query by quoting individual terms while preserving operators.

Args:
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"

Returns:
A properly formatted Boolean query with quoted terms that need quoting
"""
# Define Boolean operators and their boundaries
boolean_pattern = r'(\bAND\b|\bOR\b|\bNOT\b)'
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"

# Split the query by Boolean operators, keeping the operators
parts = re.split(boolean_pattern, query)

processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue

# If it's a Boolean operator, keep it as is
if part in ['AND', 'OR', 'NOT']:
if part in ["AND", "OR", "NOT"]:
processed_parts.append(part)
else:
# Handle parentheses specially - they should be preserved for grouping
if '(' in part or ')' in part:
if "(" in part or ")" in part:
# Parse parenthetical expressions carefully
processed_part = self._prepare_parenthetical_term(part)
processed_parts.append(processed_part)
else:
# This is a search term - for Boolean queries, don't add prefix wildcards
prepared_term = self._prepare_single_term(part, is_prefix=False)
processed_parts.append(prepared_term)

return " ".join(processed_parts)

def _prepare_parenthetical_term(self, term: str) -> str:
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.

Args:
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"

Returns:
A properly formatted term with parentheses preserved
"""
# Handle terms that start/end with parentheses but may contain quotable content
result = ""
i = 0
while i < len(term):
if term[i] in '()':
if term[i] in "()":
# Preserve parentheses as-is
result += term[i]
i += 1
else:
# Find the next parenthesis or end of string
start = i
while i < len(term) and term[i] not in '()':
while i < len(term) and term[i] not in "()":
i += 1

# Extract the content between parentheses
content = term[start:i].strip()
if content:
Expand All @@ -191,43 +191,71 @@ def _prepare_parenthetical_term(self, term: str) -> str:
result += f'"{escaped_content}"'
else:
result += content

return result

def _needs_quoting(self, term: str) -> bool:
"""Check if a term needs to be quoted for FTS5 safety.

Args:
term: The term to check

Returns:
True if the term should be quoted
"""
if not term or not term.strip():
return False

# Characters that indicate we should quote (excluding parentheses which are valid syntax)
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-", "'", '"',
"[", "]", "{", "}", "+", "!", "@", "#", "$", "%", "^", "&",
"=", "|", "\\", "~", "`"]

needs_quoting_chars = [
" ",
".",
":",
";",
",",
"<",
">",
"?",
"/",
"-",
"'",
'"',
"[",
"]",
"{",
"}",
"+",
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"=",
"|",
"\\",
"~",
"`",
]

return any(c in term for c in needs_quoting_chars)

def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a single search term (no Boolean operators).

Args:
term: A single search term
is_prefix: Whether to add prefix search capability (* suffix)

Returns:
A properly formatted single term
"""
if not term or not term.strip():
return term

term = term.strip()

# Check if term is already a proper wildcard pattern (alphanumeric + *)
# e.g., "hello*", "test*world" - these should be left alone
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
Expand Down
17 changes: 17 additions & 0 deletions src/basic_memory/services/sync_status_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,23 @@ def is_ready(self) -> bool: # pragma: no cover
"""Check if system is ready (no sync in progress)."""
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)

def is_project_ready(self, project_name: str) -> bool:
"""Check if a specific project is ready for operations.

Args:
project_name: Name of the project to check

Returns:
True if the project is ready (completed, watching, or not tracked),
False if the project is syncing, scanning, or failed
"""
project_status = self._project_statuses.get(project_name)
if not project_status:
# Project not tracked = ready (likely hasn't been synced yet)
return True

return project_status.status in (SyncStatus.COMPLETED, SyncStatus.WATCHING, SyncStatus.IDLE)

def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
"""Get status for a specific project."""
return self._project_statuses.get(project_name)
Expand Down
29 changes: 22 additions & 7 deletions tests/repository/test_search_repository.py
Loading