mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
013864ebf0
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
6.5 KiB
Python
157 lines
6.5 KiB
Python
"""Doctor command for local consistency checks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from loguru import logger
|
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
from rich.console import Console
|
|
import typer
|
|
|
|
from basic_memory.cli.app import app
|
|
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
|
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
|
from basic_memory.markdown.entity_parser import EntityParser
|
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
|
from basic_memory.mcp.async_client import get_client
|
|
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
|
|
from basic_memory.schemas.base import Entity
|
|
from basic_memory.schemas.project_info import ProjectInfoRequest
|
|
from basic_memory.schemas.search import SearchQuery
|
|
from basic_memory.schemas import SyncReportResponse
|
|
|
|
console = Console()
|
|
|
|
|
|
async def run_doctor() -> None:
|
|
"""Run local consistency checks for file <-> database flows."""
|
|
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
|
|
|
|
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
|
|
api_note_title = "Doctor API Note"
|
|
manual_note_title = "Doctor Manual Note"
|
|
manual_permalink = "doctor/manual-note"
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
async with get_client() as client:
|
|
project_client = ProjectClient(client)
|
|
project_request = ProjectInfoRequest(
|
|
name=project_name,
|
|
path=str(temp_path),
|
|
set_default=False,
|
|
)
|
|
|
|
project_id: str | None = None
|
|
|
|
try:
|
|
status = await project_client.create_project(project_request.model_dump())
|
|
if not status.new_project:
|
|
raise ValueError("Failed to create doctor project")
|
|
project_id = status.new_project.external_id
|
|
# Use the resolved path from the server — when project_root is configured,
|
|
# the actual project directory differs from the requested temp_path
|
|
project_path = Path(status.new_project.path)
|
|
console.print(f"[green]OK[/green] Created doctor project: {project_name}")
|
|
|
|
# --- DB -> File: create an entity via API ---
|
|
knowledge_client = KnowledgeClient(client, project_id)
|
|
api_note = Entity(
|
|
title=api_note_title,
|
|
directory="doctor",
|
|
note_type="note",
|
|
content_type="text/markdown",
|
|
content=f"# {api_note_title}\n\n- [note] API to file check",
|
|
entity_metadata={"tags": ["doctor"]},
|
|
)
|
|
api_result = await knowledge_client.create_entity(api_note.model_dump(), fast=False)
|
|
|
|
api_file = project_path / api_result.file_path
|
|
if not api_file.exists():
|
|
raise ValueError(f"API note file missing: {api_result.file_path}")
|
|
|
|
api_text = api_file.read_text(encoding="utf-8")
|
|
if api_note_title not in api_text:
|
|
raise ValueError("API note content missing from file")
|
|
|
|
console.print("[green]OK[/green] API write created file")
|
|
|
|
# --- File -> DB: write markdown file directly, then sync ---
|
|
parser = EntityParser(project_path)
|
|
processor = MarkdownProcessor(parser)
|
|
manual_markdown = EntityMarkdown(
|
|
frontmatter=EntityFrontmatter(
|
|
metadata={
|
|
"title": manual_note_title,
|
|
"type": "note",
|
|
"permalink": manual_permalink,
|
|
"tags": ["doctor"],
|
|
}
|
|
),
|
|
content=f"# {manual_note_title}\n\n- [note] File to DB check",
|
|
)
|
|
|
|
manual_path = project_path / "doctor" / "manual-note.md"
|
|
await processor.write_file(manual_path, manual_markdown)
|
|
console.print("[green]OK[/green] Manual file written")
|
|
|
|
sync_data = await project_client.sync(
|
|
project_id, force_full=True, run_in_background=False
|
|
)
|
|
sync_report = SyncReportResponse.model_validate(sync_data)
|
|
if sync_report.total == 0:
|
|
raise ValueError("Sync did not detect any changes")
|
|
|
|
console.print("[green]OK[/green] Sync indexed manual file")
|
|
|
|
search_client = SearchClient(client, project_id)
|
|
search_query = SearchQuery(title=manual_note_title)
|
|
search_results = await search_client.search(
|
|
search_query.model_dump(), page=1, page_size=5
|
|
)
|
|
if not any(result.title == manual_note_title for result in search_results.results):
|
|
raise ValueError("Manual note not found in search index")
|
|
|
|
console.print("[green]OK[/green] Search confirmed manual file")
|
|
|
|
status_report = await project_client.get_status(project_id)
|
|
if status_report.total != 0:
|
|
raise ValueError("Project status not clean after sync")
|
|
|
|
console.print("[green]OK[/green] Status clean after sync")
|
|
|
|
finally:
|
|
if project_id:
|
|
await project_client.delete_project(project_id)
|
|
|
|
console.print("[green]Doctor checks passed.[/green]")
|
|
|
|
|
|
@app.command()
|
|
def doctor(
|
|
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"),
|
|
) -> None:
|
|
"""Run local consistency checks to verify file/database sync."""
|
|
try:
|
|
validate_routing_flags(local, cloud)
|
|
# Doctor runs local filesystem checks — always default to local routing
|
|
if not local and not cloud:
|
|
local = True
|
|
with force_routing(local=local, cloud=cloud):
|
|
run_with_cleanup(run_doctor())
|
|
except (ToolError, ValueError) as e:
|
|
console.print(f"[red]Doctor failed: {e}[/red]")
|
|
raise typer.Exit(code=1)
|
|
except Exception as e:
|
|
logger.error(f"Doctor failed: {e}")
|
|
typer.echo(f"Doctor failed: {e}", err=True)
|
|
raise typer.Exit(code=1) # pragma: no cover
|