Skip to content

Commit 0247ef0

Browse files
phernandezclaude
andcommitted
fix(cli): defer FastAPI and app imports out of CLI startup
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>
1 parent 253e240 commit 0247ef0

30 files changed

Lines changed: 442 additions & 179 deletions

src/basic_memory/cli/commands/ci.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@
3434
from basic_memory.cli.app import app
3535
from basic_memory.cli.commands.command_utils import run_with_cleanup
3636
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
37-
from basic_memory.mcp.tools import search_notes as mcp_search_notes
38-
from basic_memory.mcp.tools import write_note as mcp_write_note
37+
38+
# MCP tool functions are imported inside the async helpers below: importing
39+
# basic_memory.mcp.tools loads the entire tool stack (fastmcp, mcp SDK,
40+
# SQLAlchemy), which would slow every CLI invocation, including --help (#886).
3941

4042

4143
console = Console()
@@ -252,6 +254,10 @@ async def seed_project_update_schemas(
252254
refresh: bool = False,
253255
) -> list[str]:
254256
"""Seed Auto BM schema notes without overwriting customized schemas."""
257+
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
258+
from basic_memory.mcp.tools import search_notes as mcp_search_notes
259+
from basic_memory.mcp.tools import write_note as mcp_write_note
260+
255261
seeded: list[str] = []
256262
routed_project = _routed_project(project=project, project_id=project_id, workspace=workspace)
257263
for spec in schema_seed_specs():
@@ -295,6 +301,10 @@ async def publish_project_update_note(
295301
note: ProjectUpdateNote,
296302
) -> dict[str, Any]:
297303
"""Search by idempotency key and then upsert the deterministic note path."""
304+
# Deferred: loading the MCP tool stack at module import slows CLI startup (#886).
305+
from basic_memory.mcp.tools import search_notes as mcp_search_notes
306+
from basic_memory.mcp.tools import write_note as mcp_write_note
307+
298308
routed_project = _routed_project(
299309
project=config.project,
300310
project_id=config.project_id,

src/basic_memory/cli/commands/command_utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
import asyncio
44
from typing import Optional, TypeVar, Coroutine, Any
55

6-
from mcp.server.fastmcp.exceptions import ToolError
76
import typer
87

98
from rich.console import Console
109

11-
from basic_memory import db
1210
from basic_memory.config import ConfigManager
1311
from basic_memory.mcp.async_client import get_client
1412
from basic_memory.mcp.clients import ProjectClient
@@ -31,6 +29,9 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
3129
Returns:
3230
The result of the coroutine
3331
"""
32+
# Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load
33+
# at CLI import time — only when a command actually runs (#886).
34+
from basic_memory import db
3435

3536
async def _with_cleanup() -> T:
3637
try:
@@ -53,6 +54,8 @@ async def run_sync(
5354
force_full: If True, force a full scan bypassing watermark optimization
5455
run_in_background: If True, return immediately; if False, wait for completion
5556
"""
57+
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
58+
from mcp.server.fastmcp.exceptions import ToolError
5659

5760
# Resolve default project so get_client() can route per-project
5861
project = project or ConfigManager().default_project
@@ -86,6 +89,9 @@ async def run_sync(
8689

8790
async def get_project_info(project: str):
8891
"""Get project information via API endpoint."""
92+
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
93+
from mcp.server.fastmcp.exceptions import ToolError
94+
8995
try:
9096
async with get_client(project_name=project) as client:
9197
project_item = await get_active_project(client, project, None)

src/basic_memory/cli/commands/db.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
"""Database management commands."""
22

3+
# PEP 563 lazy annotations let signatures reference IndexProgress without importing
4+
# the indexing stack at module load; reset/reindex import their heavy database and
5+
# sync dependencies at call time so CLI startup stays fast (#886).
6+
from __future__ import annotations
7+
38
import os
49
from dataclasses import dataclass
510
from pathlib import Path, PurePosixPath, PureWindowsPath
11+
from typing import TYPE_CHECKING
612

713
import psutil
814
import typer
915
from loguru import logger
1016
from rich.console import Console
1117
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
12-
from sqlalchemy.exc import OperationalError
1318

14-
from basic_memory import db
1519
from basic_memory.cli.app import app
1620
from basic_memory.cli.commands.command_utils import run_with_cleanup
1721
from basic_memory.config import ConfigManager, ProjectMode
18-
from basic_memory.indexing import IndexProgress
19-
from basic_memory.repository import ProjectRepository
20-
from basic_memory.services.initialization import reconcile_projects_with_config
21-
from basic_memory.sync.sync_service import get_sync_service
22+
23+
if TYPE_CHECKING:
24+
from basic_memory.indexing import IndexProgress
2225

2326
console = Console()
2427

@@ -159,6 +162,13 @@ async def _reindex_projects(app_config):
159162
This ensures all database operations use the same event loop,
160163
and proper cleanup happens when the function completes.
161164
"""
165+
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
166+
# reindex actually runs, not on every CLI start (#886).
167+
from basic_memory import db
168+
from basic_memory.repository import ProjectRepository
169+
from basic_memory.services.initialization import reconcile_projects_with_config
170+
from basic_memory.sync.sync_service import get_sync_service
171+
162172
try:
163173
await reconcile_projects_with_config(app_config)
164174

@@ -197,6 +207,12 @@ def reset(
197207
),
198208
): # pragma: no cover
199209
"""Reset database (drop all tables and recreate)."""
210+
# Deferred: SQLAlchemy and the db module load only when a reset actually
211+
# runs, not on every CLI start (#886).
212+
from sqlalchemy.exc import OperationalError
213+
214+
from basic_memory import db
215+
200216
console.print(
201217
"[yellow]Note:[/yellow] This only deletes the index database. "
202218
"Your markdown note files will not be affected.\n"
@@ -320,10 +336,15 @@ async def _reindex(
320336
project: str | None,
321337
):
322338
"""Run reindex operations."""
323-
from basic_memory.repository import EntityRepository
339+
# Deferred: SQLAlchemy, repositories, and the sync stack load only when a
340+
# reindex actually runs, not on every CLI start (#886).
341+
from basic_memory import db
342+
from basic_memory.repository import EntityRepository, ProjectRepository
324343
from basic_memory.repository.search_repository import create_search_repository
344+
from basic_memory.services.initialization import reconcile_projects_with_config
325345
from basic_memory.services.search_service import SearchService
326346
from basic_memory.services.file_service import FileService
347+
from basic_memory.sync.sync_service import get_sync_service
327348
from basic_memory.markdown.markdown_processor import MarkdownProcessor
328349
from basic_memory.markdown.entity_parser import EntityParser
329350

src/basic_memory/cli/commands/doctor.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,12 @@
77
from pathlib import Path
88

99
from loguru import logger
10-
from mcp.server.fastmcp.exceptions import ToolError
1110
from rich.console import Console
1211
import typer
1312

1413
from basic_memory.cli.app import app
1514
from basic_memory.cli.commands.command_utils import run_with_cleanup
1615
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
17-
from basic_memory.markdown.entity_parser import EntityParser
18-
from basic_memory.markdown.markdown_processor import MarkdownProcessor
19-
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
2016
from basic_memory.mcp.async_client import get_client
2117
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
2218
from basic_memory.schemas.base import Entity
@@ -29,6 +25,12 @@
2925

3026
async def run_doctor() -> None:
3127
"""Run local consistency checks for file <-> database flows."""
28+
# Deferred: the markdown parsing stack is only needed while the checks run,
29+
# and importing it at module level slows every CLI invocation (#886).
30+
from basic_memory.markdown.entity_parser import EntityParser
31+
from basic_memory.markdown.markdown_processor import MarkdownProcessor
32+
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
33+
3234
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
3335

3436
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
@@ -140,6 +142,9 @@ def doctor(
140142
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
141143
) -> None:
142144
"""Run local consistency checks to verify file/database sync."""
145+
# Deferred: ToolError lives in the mcp SDK, which must not load at CLI startup (#886).
146+
from mcp.server.fastmcp.exceptions import ToolError
147+
143148
try:
144149
validate_routing_flags(local, cloud)
145150
# Doctor runs local filesystem checks — always default to local routing

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
"""Import command for ChatGPT conversations."""
22

3+
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
4+
from __future__ import annotations
5+
36
import json
47
from pathlib import Path
5-
from typing import Annotated, Tuple
8+
from typing import TYPE_CHECKING, Annotated, Tuple
69

710
import typer
811
from basic_memory.cli.app import import_app
912
from basic_memory.cli.commands.command_utils import run_with_cleanup
1013
from basic_memory.config import ConfigManager, get_project_config
11-
from basic_memory.importers import ChatGPTImporter
12-
from basic_memory.markdown import EntityParser, MarkdownProcessor
13-
from basic_memory.services.file_service import FileService
1414
from loguru import logger
1515
from rich.console import Console
1616
from rich.panel import Panel
1717

18+
if TYPE_CHECKING:
19+
from basic_memory.markdown import MarkdownProcessor
20+
from basic_memory.services.file_service import FileService
21+
1822
console = Console()
1923

2024

2125
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
2226
"""Get MarkdownProcessor and FileService instances for importers."""
27+
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
28+
# only when an import actually runs, not on every CLI start (#886).
29+
from basic_memory.markdown import EntityParser, MarkdownProcessor
30+
from basic_memory.services.file_service import FileService
31+
2332
config = get_project_config()
2433
app_config = ConfigManager().config
2534
entity_parser = EntityParser(config.home)
@@ -60,6 +69,9 @@ def import_chatgpt(
6069
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
6170

6271
# Create importer and run import
72+
# Deferred: importer stack loads at import-command run time only (#886).
73+
from basic_memory.importers import ChatGPTImporter
74+
6375
importer = ChatGPTImporter(
6476
config.home, markdown_processor, file_service, project_name=config.name
6577
)

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
"""Import command for basic-memory CLI to import chat data from conversations2.json format."""
22

3+
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
4+
from __future__ import annotations
5+
36
import json
47
from pathlib import Path
5-
from typing import Annotated, Tuple
8+
from typing import TYPE_CHECKING, Annotated, Tuple
69

710
import typer
811
from basic_memory.cli.app import claude_app
912
from basic_memory.cli.commands.command_utils import run_with_cleanup
1013
from basic_memory.config import ConfigManager, get_project_config
11-
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
12-
from basic_memory.markdown import EntityParser, MarkdownProcessor
13-
from basic_memory.services.file_service import FileService
1414
from loguru import logger
1515
from rich.console import Console
1616
from rich.panel import Panel
1717

18+
if TYPE_CHECKING:
19+
from basic_memory.markdown import MarkdownProcessor
20+
from basic_memory.services.file_service import FileService
21+
1822
console = Console()
1923

2024

2125
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
2226
"""Get MarkdownProcessor and FileService instances for importers."""
27+
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
28+
# only when an import actually runs, not on every CLI start (#886).
29+
from basic_memory.markdown import EntityParser, MarkdownProcessor
30+
from basic_memory.services.file_service import FileService
31+
2332
config = get_project_config()
2433
app_config = ConfigManager().config
2534
entity_parser = EntityParser(config.home)
@@ -57,6 +66,9 @@ def import_claude(
5766
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5867

5968
# Create the importer
69+
# Deferred: importer stack loads at import-command run time only (#886).
70+
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
71+
6072
importer = ClaudeConversationsImporter(
6173
config.home, markdown_processor, file_service, project_name=config.name
6274
)

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
"""Import command for basic-memory CLI to import project data from Claude.ai."""
22

3+
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
4+
from __future__ import annotations
5+
36
import json
47
from pathlib import Path
5-
from typing import Annotated, Tuple
8+
from typing import TYPE_CHECKING, Annotated, Tuple
69

710
import typer
811
from basic_memory.cli.app import claude_app
912
from basic_memory.cli.commands.command_utils import run_with_cleanup
1013
from basic_memory.config import ConfigManager, get_project_config
11-
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
12-
from basic_memory.markdown import EntityParser, MarkdownProcessor
13-
from basic_memory.services.file_service import FileService
1414
from loguru import logger
1515
from rich.console import Console
1616
from rich.panel import Panel
1717

18+
if TYPE_CHECKING:
19+
from basic_memory.markdown import MarkdownProcessor
20+
from basic_memory.services.file_service import FileService
21+
1822
console = Console()
1923

2024

2125
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
2226
"""Get MarkdownProcessor and FileService instances for importers."""
27+
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
28+
# only when an import actually runs, not on every CLI start (#886).
29+
from basic_memory.markdown import EntityParser, MarkdownProcessor
30+
from basic_memory.services.file_service import FileService
31+
2332
config = get_project_config()
2433
app_config = ConfigManager().config
2534
entity_parser = EntityParser(config.home)
@@ -56,6 +65,9 @@ def import_projects(
5665
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5766

5867
# Create the importer
68+
# Deferred: importer stack loads at import-command run time only (#886).
69+
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
70+
5971
importer = ClaudeProjectsImporter(
6072
config.home, markdown_processor, file_service, project_name=config.name
6173
)

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
"""Import command for basic-memory CLI to import from JSON memory format."""
22

3+
# PEP 563 lazy annotations keep heavy importer types out of module import (#886).
4+
from __future__ import annotations
5+
36
import json
47
from pathlib import Path
5-
from typing import Annotated, Tuple
8+
from typing import TYPE_CHECKING, Annotated, Tuple
69

710
import typer
811
from basic_memory.cli.app import import_app
912
from basic_memory.cli.commands.command_utils import run_with_cleanup
1013
from basic_memory.config import ConfigManager, get_project_config
11-
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
12-
from basic_memory.markdown import EntityParser, MarkdownProcessor
13-
from basic_memory.services.file_service import FileService
1414
from loguru import logger
1515
from rich.console import Console
1616
from rich.panel import Panel
1717

18+
if TYPE_CHECKING:
19+
from basic_memory.markdown import MarkdownProcessor
20+
from basic_memory.services.file_service import FileService
21+
1822
console = Console()
1923

2024

2125
async def get_importer_dependencies() -> Tuple[MarkdownProcessor, FileService]:
2226
"""Get MarkdownProcessor and FileService instances for importers."""
27+
# Deferred: the markdown/file-service stack pulls SQLAlchemy and must load
28+
# only when an import actually runs, not on every CLI start (#886).
29+
from basic_memory.markdown import EntityParser, MarkdownProcessor
30+
from basic_memory.services.file_service import FileService
31+
2332
config = get_project_config()
2433
app_config = ConfigManager().config
2534
entity_parser = EntityParser(config.home)
@@ -55,6 +64,9 @@ def memory_json(
5564
markdown_processor, file_service = run_with_cleanup(get_importer_dependencies())
5665

5766
# Create the importer
67+
# Deferred: importer stack loads at import-command run time only (#886).
68+
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
69+
5870
importer = MemoryJsonImporter(
5971
config.home, markdown_processor, file_service, project_name=config.name
6072
)

0 commit comments

Comments
 (0)