mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0239f4abb4
Signed-off-by: phernandez <paul@basicmachines.co>
443 lines
12 KiB
Markdown
443 lines
12 KiB
Markdown
# Basic Memory Architecture
|
|
|
|
This document describes the architectural patterns and composition structure of Basic Memory.
|
|
|
|
## Overview
|
|
|
|
Basic Memory is a local-first knowledge management system with three entrypoints:
|
|
- **API** - FastAPI REST server for HTTP access
|
|
- **MCP** - Model Context Protocol server for LLM integration
|
|
- **CLI** - Typer command-line interface
|
|
|
|
Each entrypoint uses a **composition root** pattern to manage configuration and dependencies.
|
|
|
|
## Composition Roots
|
|
|
|
### What is a Composition Root?
|
|
|
|
A composition root is the single place in an application where dependencies are wired together. In Basic Memory, each entrypoint has its own composition root that:
|
|
|
|
1. Reads configuration from `ConfigManager`
|
|
2. Resolves runtime mode (local/test)
|
|
3. Creates and provides dependencies to downstream code
|
|
|
|
**Key principle**: Only composition roots read global configuration. All other modules receive configuration explicitly.
|
|
|
|
### Container Structure
|
|
|
|
Each entrypoint has a container dataclass in its package:
|
|
|
|
```
|
|
src/basic_memory/
|
|
├── api/
|
|
│ └── container.py # ApiContainer
|
|
├── mcp/
|
|
│ └── container.py # McpContainer
|
|
├── cli/
|
|
│ └── container.py # CliContainer
|
|
└── runtime.py # RuntimeMode enum and resolver
|
|
```
|
|
|
|
### Container Pattern
|
|
|
|
All containers follow the same structure:
|
|
|
|
```python
|
|
@dataclass
|
|
class Container:
|
|
config: BasicMemoryConfig
|
|
mode: RuntimeMode
|
|
|
|
@classmethod
|
|
def create(cls) -> "Container":
|
|
"""Create container by reading ConfigManager."""
|
|
config = ConfigManager().config
|
|
mode = resolve_runtime_mode(is_test_env=config.is_test_env)
|
|
return cls(config=config, mode=mode)
|
|
|
|
@property
|
|
def some_computed_property(self) -> bool:
|
|
"""Derived values based on config and mode."""
|
|
return self.mode.is_local and self.config.some_setting
|
|
|
|
# Module-level singleton
|
|
_container: Container | None = None
|
|
|
|
def get_container() -> Container:
|
|
if _container is None:
|
|
raise RuntimeError("Container not initialized")
|
|
return _container
|
|
|
|
def set_container(container: Container) -> None:
|
|
global _container
|
|
_container = container
|
|
```
|
|
|
|
### Runtime Mode Resolution
|
|
|
|
The `RuntimeMode` enum centralizes mode detection:
|
|
|
|
```python
|
|
class RuntimeMode(Enum):
|
|
LOCAL = "local"
|
|
CLOUD = "cloud"
|
|
TEST = "test"
|
|
|
|
@property
|
|
def is_cloud(self) -> bool:
|
|
return self == RuntimeMode.CLOUD
|
|
|
|
@property
|
|
def is_local(self) -> bool:
|
|
return self == RuntimeMode.LOCAL
|
|
|
|
@property
|
|
def is_test(self) -> bool:
|
|
return self == RuntimeMode.TEST
|
|
```
|
|
|
|
Resolution follows this precedence in local app flows: **TEST > LOCAL**
|
|
|
|
```python
|
|
def resolve_runtime_mode(is_test_env: bool) -> RuntimeMode:
|
|
if is_test_env:
|
|
return RuntimeMode.TEST
|
|
return RuntimeMode.LOCAL
|
|
```
|
|
|
|
**Note**: `RuntimeMode` determines global behavior (e.g., whether to start file sync).
|
|
Per-project routing is orthogonal: individual projects can be set to `cloud` mode via `ProjectMode`,
|
|
which affects client routing in `get_client(project_name=...)` without changing global runtime mode.
|
|
`RuntimeMode.CLOUD` may remain for compatibility, but standard local runtime resolution does not select it.
|
|
|
|
## Dependencies Package
|
|
|
|
### Structure
|
|
|
|
The `deps/` package provides FastAPI dependencies organized by feature:
|
|
|
|
```
|
|
src/basic_memory/deps/
|
|
├── __init__.py # Re-exports for backwards compatibility
|
|
├── config.py # Configuration access
|
|
├── db.py # Database/session management
|
|
├── projects.py # Project resolution
|
|
├── repositories.py # Data access layer
|
|
├── services.py # Business logic layer
|
|
└── importers.py # Import functionality
|
|
```
|
|
|
|
### Usage in Routers
|
|
|
|
```python
|
|
from basic_memory.deps.services import get_entity_service
|
|
from basic_memory.deps.projects import get_project_config
|
|
|
|
@router.get("/entities/{id}")
|
|
async def get_entity(
|
|
id: int,
|
|
entity_service: EntityService = Depends(get_entity_service),
|
|
project: ProjectConfig = Depends(get_project_config),
|
|
):
|
|
return await entity_service.get(id)
|
|
```
|
|
|
|
### Backwards Compatibility
|
|
|
|
The old `deps.py` file still exists as a thin re-export shim:
|
|
|
|
```python
|
|
# deps.py - backwards compatibility shim
|
|
from basic_memory.deps import *
|
|
```
|
|
|
|
New code should import from specific submodules (`basic_memory.deps.services`) for clarity.
|
|
|
|
## MCP Tools Architecture
|
|
|
|
### Typed API Clients
|
|
|
|
MCP tools communicate with the API through typed clients that encapsulate HTTP paths and response validation:
|
|
|
|
```
|
|
src/basic_memory/mcp/clients/
|
|
├── __init__.py # Re-exports all clients
|
|
├── base.py # BaseClient with common logic
|
|
├── knowledge.py # KnowledgeClient - entity CRUD
|
|
├── search.py # SearchClient - search operations
|
|
├── memory.py # MemoryClient - context building
|
|
├── directory.py # DirectoryClient - directory listing
|
|
├── resource.py # ResourceClient - resource reading
|
|
└── project.py # ProjectClient - project management
|
|
```
|
|
|
|
### Client Pattern
|
|
|
|
Each client encapsulates API paths and validates responses:
|
|
|
|
```python
|
|
class KnowledgeClient(BaseClient):
|
|
"""Client for knowledge/entity operations."""
|
|
|
|
async def resolve_entity(self, identifier: str) -> int:
|
|
"""Resolve identifier to entity ID."""
|
|
response = await call_get(
|
|
self.http_client,
|
|
f"{self._base_path}/resolve/{identifier}",
|
|
)
|
|
return int(response.text)
|
|
|
|
async def get_entity(self, entity_id: int) -> EntityResponse:
|
|
"""Get entity by ID."""
|
|
response = await call_get(
|
|
self.http_client,
|
|
f"{self._base_path}/entities/{entity_id}",
|
|
)
|
|
return EntityResponse.model_validate(response.json())
|
|
```
|
|
|
|
### Tool → Client → API Flow
|
|
|
|
```
|
|
MCP Tool (thin adapter)
|
|
↓
|
|
Typed Client (encapsulates paths, validates responses)
|
|
↓
|
|
HTTP API (FastAPI router)
|
|
↓
|
|
Service Layer (business logic)
|
|
↓
|
|
Repository Layer (data access)
|
|
```
|
|
|
|
Example tool using typed client:
|
|
|
|
```python
|
|
@mcp.tool()
|
|
async def search_notes(
|
|
query: str,
|
|
project: str | None = None,
|
|
metadata_filters: dict | None = None,
|
|
tags: list[str] | None = None,
|
|
status: str | None = None,
|
|
) -> SearchResponse:
|
|
async with get_project_client(project, context) as (client, active_project):
|
|
# Import client inside function to avoid circular imports
|
|
from basic_memory.mcp.clients import SearchClient
|
|
from basic_memory.schemas.search import SearchQuery
|
|
|
|
search_query = SearchQuery(
|
|
text=query,
|
|
metadata_filters=metadata_filters,
|
|
tags=tags,
|
|
status=status,
|
|
)
|
|
search_client = SearchClient(client, active_project.external_id)
|
|
return await search_client.search(search_query.model_dump())
|
|
```
|
|
|
|
### Per-Project Client Routing
|
|
|
|
`get_project_client()` from `mcp/project_context.py` is an async context manager that:
|
|
1. Resolves the project name from config (no network call)
|
|
2. Creates the correctly-routed client based on the project's mode (local ASGI or cloud HTTP with API key)
|
|
3. Validates the project via the API
|
|
4. Yields `(client, active_project)` tuple
|
|
|
|
This solves the bootstrap problem: you need the project name to choose the right client (local vs cloud), but you need the client to validate the project exists.
|
|
|
|
```python
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
|
|
async with get_project_client(project, context) as (client, active_project):
|
|
# client is routed based on project's mode (local or cloud)
|
|
# active_project is validated via the API
|
|
...
|
|
```
|
|
|
|
## Sync Coordination
|
|
|
|
### SyncCoordinator
|
|
|
|
The `SyncCoordinator` centralizes sync/watch lifecycle management:
|
|
|
|
```python
|
|
@dataclass
|
|
class SyncCoordinator:
|
|
"""Coordinates file sync and watch operations."""
|
|
|
|
status: SyncStatus = SyncStatus.NOT_STARTED
|
|
sync_task: asyncio.Task | None = None
|
|
watch_service: WatchService | None = None
|
|
|
|
async def start(self, ...):
|
|
"""Start sync and watch operations."""
|
|
|
|
async def stop(self):
|
|
"""Stop all sync operations gracefully."""
|
|
|
|
def get_status_info(self) -> dict:
|
|
"""Get current sync status for observability."""
|
|
```
|
|
|
|
### Status Enum
|
|
|
|
```python
|
|
class SyncStatus(Enum):
|
|
NOT_STARTED = "not_started"
|
|
STARTING = "starting"
|
|
RUNNING = "running"
|
|
STOPPING = "stopping"
|
|
STOPPED = "stopped"
|
|
ERROR = "error"
|
|
```
|
|
|
|
## Project Resolution
|
|
|
|
### ProjectResolver
|
|
|
|
Unified project selection across all entrypoints:
|
|
|
|
```python
|
|
class ProjectResolver:
|
|
"""Resolves which project to use based on context."""
|
|
|
|
def resolve(
|
|
self,
|
|
explicit_project: str | None = None,
|
|
) -> ResolvedProject:
|
|
"""Resolve project using three-tier hierarchy:
|
|
1. Explicit project parameter
|
|
2. Default project from config
|
|
3. Single available project
|
|
"""
|
|
```
|
|
|
|
### Resolution Modes
|
|
|
|
```python
|
|
class ResolutionMode(Enum):
|
|
EXPLICIT = "explicit" # User specified project
|
|
DEFAULT = "default" # Using configured default
|
|
SINGLE_PROJECT = "single" # Only one project exists
|
|
FALLBACK = "fallback" # Using first available
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### Container Testing
|
|
|
|
Each container has corresponding tests:
|
|
|
|
```
|
|
tests/
|
|
├── api/test_api_container.py
|
|
├── mcp/test_mcp_container.py
|
|
└── cli/test_cli_container.py
|
|
```
|
|
|
|
Tests verify:
|
|
- Container creation from config
|
|
- Runtime mode properties
|
|
- Container accessor functions (get/set)
|
|
|
|
### Mocking Typed Clients
|
|
|
|
When testing MCP tools, mock at the client level:
|
|
|
|
```python
|
|
def test_search_notes(monkeypatch):
|
|
import basic_memory.mcp.clients as clients_mod
|
|
|
|
class MockSearchClient:
|
|
async def search(self, query):
|
|
return SearchResponse(results=[...])
|
|
|
|
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
|
|
```
|
|
|
|
## Design Principles
|
|
|
|
### 1. Explicit Dependencies
|
|
|
|
Modules receive configuration explicitly rather than reading globals:
|
|
|
|
```python
|
|
# Good - explicit injection
|
|
async def sync_files(config: BasicMemoryConfig):
|
|
...
|
|
|
|
# Avoid - hidden global access
|
|
async def sync_files():
|
|
config = ConfigManager().config # Hidden coupling
|
|
```
|
|
|
|
### 2. Single Responsibility
|
|
|
|
Each layer has a clear responsibility:
|
|
- **Containers**: Wire dependencies
|
|
- **Clients**: Encapsulate HTTP communication
|
|
- **Services**: Business logic
|
|
- **Repositories**: Data access
|
|
- **Tools/Routers**: Thin adapters
|
|
|
|
### 3. Deferred Imports
|
|
|
|
To avoid circular imports, typed clients are imported inside functions:
|
|
|
|
```python
|
|
async def my_tool():
|
|
async with get_client() as client:
|
|
# Import here to avoid circular dependency
|
|
from basic_memory.mcp.clients import KnowledgeClient
|
|
|
|
knowledge_client = KnowledgeClient(client, project_id)
|
|
```
|
|
|
|
### 4. Backwards Compatibility
|
|
|
|
When refactoring, maintain backwards compatibility via shims:
|
|
|
|
```python
|
|
# Old module becomes a shim
|
|
from basic_memory.new_location import *
|
|
|
|
# Docstring explains migration path
|
|
"""
|
|
DEPRECATED: Import from basic_memory.new_location instead.
|
|
This shim will be removed in a future version.
|
|
"""
|
|
```
|
|
|
|
## File Organization
|
|
|
|
```
|
|
src/basic_memory/
|
|
├── api/
|
|
│ ├── container.py # API composition root
|
|
│ ├── routers/ # FastAPI routers
|
|
│ └── ...
|
|
├── mcp/
|
|
│ ├── container.py # MCP composition root
|
|
│ ├── clients/ # Typed API clients
|
|
│ ├── tools/ # MCP tool definitions
|
|
│ └── server.py # MCP server setup
|
|
├── cli/
|
|
│ ├── container.py # CLI composition root
|
|
│ ├── app.py # Typer app
|
|
│ └── commands/ # CLI command groups
|
|
├── deps/
|
|
│ ├── config.py # Config dependencies
|
|
│ ├── db.py # Database dependencies
|
|
│ ├── projects.py # Project dependencies
|
|
│ ├── repositories.py # Repository dependencies
|
|
│ ├── services.py # Service dependencies
|
|
│ └── importers.py # Importer dependencies
|
|
├── sync/
|
|
│ ├── coordinator.py # SyncCoordinator
|
|
│ └── ...
|
|
├── runtime.py # RuntimeMode resolution
|
|
├── project_resolver.py # Unified project selection
|
|
└── config.py # Configuration management
|
|
```
|