mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0247ef0ead
Every basic-memory CLI invocation paid roughly 2 seconds of module-import cost before any work started, which blew the Claude Code plugin's SessionStart hook budget on cold machines (#886). The cost came from module-level imports that pulled the entire server stack into CLI startup: - mcp/async_client.py imported FastAPI at module level, so every consumer of get_client() loaded FastAPI even for cloud-routed or help-only paths. - mcp/clients/*.py imported call_* helpers from basic_memory.mcp.tools.utils, which executes the whole tools package __init__ — every MCP tool module plus fastmcp and the mcp SDK. - mcp/project_context.py imported fastmcp.Context and ToolError eagerly. - CLI command modules (tool, ci, schema) imported MCP tool functions at module level; db and the import_* commands pulled SQLAlchemy/Alembic and the markdown/file-service stack; status/doctor/orphans/command_utils imported ToolError (the mcp SDK) and basic_memory.db. - schemas/base.py imported dateparser (~0.13s) for one helper function. The fix only defers imports to the point of use (no behavior changes): FastAPI now loads inside _resolve_local_asgi_database alongside the existing lazy api.app import, so it is only paid when a request actually routes through the in-process ASGI transport; the typed clients import call_* per method; project_context uses PEP 563 annotations with Context under TYPE_CHECKING; the CLI command modules import their heavy dependencies inside the command bodies. Tests that patched the old module-level aliases now patch the source modules instead. Measured on a warm cache (python -X importtime / wall time): - import basic_memory.cli.main: 1.92s -> 0.45s - bm --help: 2.40s -> 0.52s - bm tool search-notes --help: 2.40s -> 0.86s A regression test asserts that importing the CLI entry module with full command registration leaves fastapi, sqlalchemy, alembic, fastmcp, mcp, basic_memory.api.app, basic_memory.db, basic_memory.markdown, basic_memory.mcp.tools, and basic_memory.services out of sys.modules. Fixes #886 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""Status command for basic-memory CLI."""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from typing import Annotated, Dict, Optional, Set
|
|
|
|
import typer
|
|
from loguru import logger
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.tree import Tree
|
|
|
|
from basic_memory.cli.app import app
|
|
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
|
from basic_memory.config import ConfigManager
|
|
from basic_memory.mcp.async_client import get_client
|
|
from basic_memory.mcp.clients import ProjectClient
|
|
from basic_memory.schemas import SyncReportResponse
|
|
from basic_memory.mcp.project_context import get_active_project
|
|
|
|
# Create rich console
|
|
console = Console()
|
|
|
|
|
|
def add_files_to_tree(
|
|
tree: Tree, paths: Set[str], style: str, checksums: Dict[str, str] | None = None
|
|
):
|
|
"""Add files to tree, grouped by directory."""
|
|
# Group by directory
|
|
by_dir = {}
|
|
for path in sorted(paths):
|
|
parts = path.split("/", 1)
|
|
dir_name = parts[0] if len(parts) > 1 else ""
|
|
file_name = parts[1] if len(parts) > 1 else parts[0]
|
|
by_dir.setdefault(dir_name, []).append((file_name, path))
|
|
|
|
# Add to tree
|
|
for dir_name, files in sorted(by_dir.items()):
|
|
if dir_name:
|
|
branch = tree.add(f"[bold]{dir_name}/[/bold]")
|
|
else:
|
|
branch = tree
|
|
|
|
for file_name, full_path in sorted(files):
|
|
if checksums and full_path in checksums:
|
|
checksum_short = checksums[full_path][:8]
|
|
branch.add(f"[{style}]{file_name}[/{style}] ({checksum_short})")
|
|
else:
|
|
branch.add(f"[{style}]{file_name}[/{style}]")
|
|
|
|
|
|
def group_changes_by_directory(changes: SyncReportResponse) -> Dict[str, Dict[str, int]]:
|
|
"""Group changes by directory for summary view."""
|
|
by_dir = {}
|
|
for change_type, paths in [
|
|
("new", changes.new),
|
|
("modified", changes.modified),
|
|
("deleted", changes.deleted),
|
|
]:
|
|
for path in paths:
|
|
dir_name = path.split("/", 1)[0]
|
|
by_dir.setdefault(dir_name, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
|
|
by_dir[dir_name][change_type] += 1
|
|
|
|
# Handle moves - count in both source and destination directories
|
|
for old_path, new_path in changes.moves.items():
|
|
old_dir = old_path.split("/", 1)[0]
|
|
new_dir = new_path.split("/", 1)[0]
|
|
by_dir.setdefault(old_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
|
|
by_dir.setdefault(new_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0})
|
|
by_dir[old_dir]["moved"] += 1
|
|
if old_dir != new_dir:
|
|
by_dir[new_dir]["moved"] += 1
|
|
|
|
return by_dir
|
|
|
|
|
|
def build_directory_summary(counts: Dict[str, int]) -> str:
|
|
"""Build summary string for directory changes."""
|
|
parts = []
|
|
if counts["new"]:
|
|
parts.append(f"[green]+{counts['new']} new[/green]")
|
|
if counts["modified"]:
|
|
parts.append(f"[yellow]~{counts['modified']} modified[/yellow]")
|
|
if counts["moved"]:
|
|
parts.append(f"[blue]↔{counts['moved']} moved[/blue]")
|
|
if counts["deleted"]:
|
|
parts.append(f"[red]-{counts['deleted']} deleted[/red]")
|
|
return " ".join(parts)
|
|
|
|
|
|
def display_changes(
|
|
project_name: str, title: str, changes: SyncReportResponse, verbose: bool = False
|
|
):
|
|
"""Display changes using Rich for better visualization."""
|
|
tree = Tree(f"{project_name}: {title}")
|
|
|
|
if changes.total == 0 and not changes.skipped_files:
|
|
tree.add("No changes")
|
|
console.print(Panel(tree, expand=False))
|
|
return
|
|
|
|
if verbose:
|
|
# Full file listing with checksums
|
|
if changes.new:
|
|
new_branch = tree.add("[green]New Files[/green]")
|
|
add_files_to_tree(new_branch, changes.new, "green", changes.checksums)
|
|
if changes.modified:
|
|
mod_branch = tree.add("[yellow]Modified[/yellow]")
|
|
add_files_to_tree(mod_branch, changes.modified, "yellow", changes.checksums)
|
|
if changes.moves:
|
|
move_branch = tree.add("[blue]Moved[/blue]")
|
|
for old_path, new_path in sorted(changes.moves.items()):
|
|
move_branch.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue]")
|
|
if changes.deleted:
|
|
del_branch = tree.add("[red]Deleted[/red]")
|
|
add_files_to_tree(del_branch, changes.deleted, "red")
|
|
if changes.skipped_files:
|
|
skip_branch = tree.add("[red]! Skipped (Circuit Breaker)[/red]")
|
|
for skipped in sorted(changes.skipped_files, key=lambda x: x.path):
|
|
skip_branch.add(
|
|
f"[red]{skipped.path}[/red] "
|
|
f"(failures: {skipped.failure_count}, reason: {skipped.reason})"
|
|
)
|
|
else:
|
|
# Show directory summaries
|
|
by_dir = group_changes_by_directory(changes)
|
|
for dir_name, counts in sorted(by_dir.items()):
|
|
summary = build_directory_summary(counts)
|
|
if summary: # Only show directories with changes
|
|
tree.add(f"[bold]{dir_name}/[/bold] {summary}")
|
|
|
|
# Show skipped files summary in non-verbose mode
|
|
if changes.skipped_files:
|
|
skip_count = len(changes.skipped_files)
|
|
tree.add(
|
|
f"[red]! {skip_count} file{'s' if skip_count != 1 else ''} "
|
|
f"skipped due to repeated failures[/red]"
|
|
)
|
|
|
|
console.print(Panel(tree, expand=False))
|
|
|
|
|
|
class StatusTimeout(Exception):
|
|
"""Raised when --wait does not reach a synced state before the deadline."""
|
|
|
|
|
|
async def run_status(
|
|
project: Optional[str] = None,
|
|
wait: bool = False,
|
|
timeout: float = 30.0,
|
|
poll_interval: float = 0.5,
|
|
) -> tuple[str, SyncReportResponse]:
|
|
"""Fetch sync status of files vs database.
|
|
|
|
When ``wait`` is False this performs a single live disk-vs-DB scan and
|
|
returns immediately. When ``wait`` is True it polls until the project has
|
|
no pending changes (``sync_report.total == 0``) or the timeout elapses.
|
|
|
|
Returns (project_name, sync_report) for the caller to render.
|
|
|
|
Raises:
|
|
StatusTimeout: If ``wait`` is True and the deadline passes before the
|
|
project reaches a synced state.
|
|
"""
|
|
# Resolve default project so get_client() can route per-project
|
|
project = project or ConfigManager().default_project
|
|
|
|
# Reuse a single client/context across polls so we don't reconnect each loop.
|
|
async with get_client(project_name=project) as client:
|
|
project_item = await get_active_project(client, project, None)
|
|
project_client = ProjectClient(client)
|
|
|
|
# Trigger: caller did not request --wait
|
|
# Why: preserve the original single-scan behavior for the common case
|
|
# Outcome: one status scan, returned as-is
|
|
if not wait:
|
|
sync_report = await project_client.get_status(project_item.external_id)
|
|
return project_item.name, sync_report
|
|
|
|
# Trigger: --wait requested
|
|
# Why: callers (bulk imports, benchmarks, tests) need to block until the
|
|
# index has caught up instead of polling externally
|
|
# Outcome: poll get_status until total == 0 or the deadline is reached
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
sync_report = await project_client.get_status(project_item.external_id)
|
|
if sync_report.total == 0:
|
|
return project_item.name, sync_report
|
|
if time.monotonic() >= deadline:
|
|
# Why the hint: indexing is done by the sync coordinator, which
|
|
# only runs inside a live server (bm mcp / hosted API). In a
|
|
# CLI-only session nothing will ever drain the pending count,
|
|
# so this wait cannot succeed — point at the command that
|
|
# actually indexes (#959).
|
|
raise StatusTimeout(
|
|
f"Timed out after {timeout:g}s waiting for '{project_item.name}' "
|
|
f"to finish indexing ({sync_report.total} pending change(s) remaining). "
|
|
f"If no Basic Memory server is running, pending changes are never "
|
|
f"indexed — run 'bm reindex --project {project_item.name}' instead."
|
|
)
|
|
await asyncio.sleep(poll_interval)
|
|
|
|
|
|
@app.command()
|
|
def status(
|
|
project: Annotated[
|
|
Optional[str],
|
|
typer.Option(help="The project name."),
|
|
] = None,
|
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
|
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
|
wait: bool = typer.Option(
|
|
False, "--wait", help="Block until indexing is complete (no pending changes)"
|
|
),
|
|
timeout: float = typer.Option(30.0, "--timeout", help="Max seconds to wait when --wait is set"),
|
|
local: bool = typer.Option(
|
|
False, "--local", help="Force local API routing (ignore cloud mode)"
|
|
),
|
|
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
|
):
|
|
"""Show sync status between files and database.
|
|
|
|
Use --json for machine-readable output.
|
|
Use --wait to block until indexing is complete (e.g. after a bulk import);
|
|
combine with --timeout to bound the wait. On timeout the command exits 1.
|
|
Use --local to force local routing when cloud mode is enabled.
|
|
Use --cloud to force cloud routing when cloud mode is disabled.
|
|
"""
|
|
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
|
|
|
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
|
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
|
|
# Trigger: --wait with a negative --timeout
|
|
# Why: a negative deadline times out on the very first poll, producing a confusing
|
|
# "Timed out after -5s" message instead of flagging the bad input. Raised
|
|
# before the try/except so typer renders a clean usage error (exit 2).
|
|
# Outcome: reject it up front with a clear parameter error.
|
|
if wait and timeout < 0:
|
|
raise typer.BadParameter("--timeout must be >= 0", param_hint="'--timeout'")
|
|
|
|
try:
|
|
validate_routing_flags(local, cloud)
|
|
# Trigger: no explicit routing flag provided
|
|
# Why: status scans the local filesystem — cloud routing would use the
|
|
# Docker-internal path stored in the cloud database, which doesn't
|
|
# exist locally.
|
|
# Outcome: default to local routing unless --cloud was explicitly requested.
|
|
if not local and not cloud:
|
|
local = True
|
|
with force_routing(local=local, cloud=cloud):
|
|
project_name, sync_report = run_with_cleanup(
|
|
run_status(project, wait=wait, timeout=timeout)
|
|
)
|
|
|
|
if json_output:
|
|
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
|
|
else:
|
|
display_changes(project_name, "Status", sync_report, verbose)
|
|
except StatusTimeout as e:
|
|
# Trigger: --wait deadline passed before the project finished indexing
|
|
# Why: callers depend on exit code 1 to detect that indexing did not
|
|
# complete in time, while still getting a clear machine/human message
|
|
# Outcome: emit the timeout message (JSON-shaped under --json) and exit 1
|
|
if json_output:
|
|
print(json.dumps({"error": str(e)}, indent=2))
|
|
else:
|
|
console.print(f"[red]Error: {e}[/red]")
|
|
raise typer.Exit(code=1)
|
|
except (ValueError, ToolError) as e:
|
|
if json_output:
|
|
print(json.dumps({"error": str(e)}, indent=2))
|
|
else:
|
|
console.print(f"[red]Error: {e}[/red]")
|
|
raise typer.Exit(code=1)
|
|
except typer.Exit:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error checking status: {e}")
|
|
if json_output:
|
|
print(json.dumps({"error": str(e)}, indent=2))
|
|
else:
|
|
typer.echo(f"Error checking status: {e}", err=True)
|
|
raise typer.Exit(code=1) # pragma: no cover
|