mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eeeade4f07 | |||
| 03793eaf7c | |||
| 26f7e98932 | |||
| 5947f04bd3 |
@@ -54,6 +54,7 @@ jobs:
|
||||
- [ ] Unit tests for new functions/methods
|
||||
- [ ] Integration tests for new MCP tools
|
||||
- [ ] Test coverage for edge cases
|
||||
- [ ] **100% test coverage maintained** (use `# pragma: no cover` only for truly hard-to-test code)
|
||||
- [ ] Documentation updated (README, docstrings)
|
||||
- [ ] CLAUDE.md updated if conventions change
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.17.4 (2026-01-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#503**: Preserve search index across server restarts
|
||||
([`26f7e98`](https://github.com/basicmachines-co/basic-memory/commit/26f7e98))
|
||||
- Fixes critical bug where search index was wiped on every MCP server restart
|
||||
- Bug was introduced in v0.16.3, affecting v0.16.3-v0.17.3
|
||||
- **User action**: Run `basic-memory reset` once after updating to rebuild search index
|
||||
|
||||
### Internal
|
||||
|
||||
- **#502**: Major architecture refactor with composition roots and typed API clients
|
||||
([`5947f04`](https://github.com/basicmachines-co/basic-memory/commit/5947f04))
|
||||
- Add composition roots for API, MCP, and CLI entrypoints
|
||||
- Split deps.py into feature-scoped modules (config, db, projects, repositories, services, importers)
|
||||
- Add ProjectResolver for unified project selection
|
||||
- Add SyncCoordinator for centralized sync/watch lifecycle
|
||||
- Introduce typed API clients for MCP tools (KnowledgeClient, SearchClient, MemoryClient, etc.)
|
||||
|
||||
## v0.17.3 (2026-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -117,17 +117,38 @@ counter += 1 # track retries for backoff calculation
|
||||
|
||||
### Codebase Architecture
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture documentation.
|
||||
|
||||
**Directory Structure:**
|
||||
- `/alembic` - Alembic db migrations
|
||||
- `/api` - FastAPI implementation of REST endpoints
|
||||
- `/cli` - Typer command-line interface
|
||||
- `/api` - FastAPI REST endpoints + `container.py` composition root
|
||||
- `/cli` - Typer CLI + `container.py` composition root
|
||||
- `/deps` - Feature-scoped FastAPI dependencies (config, db, projects, repositories, services, importers)
|
||||
- `/importers` - Import functionality for Claude, ChatGPT, and other sources
|
||||
- `/markdown` - Markdown parsing and processing
|
||||
- `/mcp` - Model Context Protocol server implementation
|
||||
- `/mcp` - MCP server + `container.py` composition root + `clients/` typed API clients
|
||||
- `/models` - SQLAlchemy ORM models
|
||||
- `/repository` - Data access layer
|
||||
- `/schemas` - Pydantic models for validation
|
||||
- `/services` - Business logic layer
|
||||
- `/sync` - File synchronization services
|
||||
- `/sync` - File synchronization services + `coordinator.py` for lifecycle management
|
||||
|
||||
**Composition Roots:**
|
||||
Each entrypoint (API, MCP, CLI) has a composition root that:
|
||||
- Reads `ConfigManager` (the only place that reads global config)
|
||||
- Resolves runtime mode via `RuntimeMode` enum (TEST > CLOUD > LOCAL)
|
||||
- Provides dependencies to downstream code explicitly
|
||||
|
||||
**Typed API Clients (MCP):**
|
||||
MCP tools use typed clients in `mcp/clients/` to communicate with the API:
|
||||
- `KnowledgeClient` - Entity CRUD operations
|
||||
- `SearchClient` - Search operations
|
||||
- `MemoryClient` - Context building
|
||||
- `DirectoryClient` - Directory listing
|
||||
- `ResourceClient` - Resource reading
|
||||
- `ProjectClient` - Project management
|
||||
|
||||
Flow: MCP Tool → Typed Client → HTTP API → Router → Service → Repository
|
||||
|
||||
### Development Notes
|
||||
|
||||
@@ -146,6 +167,7 @@ counter += 1 # track retries for backoff calculation
|
||||
- CI runs SQLite and Postgres tests in parallel for faster feedback
|
||||
- Performance benchmarks are in `test-int/test_sync_performance_benchmark.py`
|
||||
- Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
- **Coverage must stay at 100%**: Write tests for new code. Only use `# pragma: no cover` when tests would require excessive mocking (e.g., TYPE_CHECKING blocks, error handlers that need failure injection, runtime-mode-dependent code paths)
|
||||
|
||||
### Async Client Pattern (Important!)
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# 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 (cloud/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(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
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: **TEST > CLOUD > LOCAL**
|
||||
|
||||
```python
|
||||
def resolve_runtime_mode(cloud_mode_enabled: bool, is_test_env: bool) -> RuntimeMode:
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
return RuntimeMode.LOCAL
|
||||
```
|
||||
|
||||
## 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) -> SearchResponse:
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project)
|
||||
|
||||
# Import client inside function to avoid circular imports
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
return await search_client.search(query)
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.17.3"
|
||||
__version__ = "0.17.4"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
+20
-32
@@ -1,6 +1,5 @@
|
||||
"""FastAPI application for basic-memory knowledge graph API."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -8,7 +7,7 @@ from fastapi.exception_handlers import http_exception_handler
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import __version__ as version
|
||||
from basic_memory import db
|
||||
from basic_memory.api.container import ApiContainer, set_container
|
||||
from basic_memory.api.routers import (
|
||||
directory_router,
|
||||
importer_router,
|
||||
@@ -30,8 +29,8 @@ from basic_memory.api.v2.routers import (
|
||||
prompt_router as v2_prompt,
|
||||
importer_router as v2_importer,
|
||||
)
|
||||
from basic_memory.config import ConfigManager, init_api_logging
|
||||
from basic_memory.services.initialization import initialize_file_sync, initialize_app
|
||||
from basic_memory.config import init_api_logging
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -41,47 +40,36 @@ async def lifespan(app: FastAPI): # pragma: no cover
|
||||
# Initialize logging for API (stdout in cloud mode, file otherwise)
|
||||
init_api_logging()
|
||||
|
||||
app_config = ConfigManager().config
|
||||
logger.info("Starting Basic Memory API")
|
||||
# --- Composition Root ---
|
||||
# Create container and read config (single point of config access)
|
||||
container = ApiContainer.create()
|
||||
set_container(container)
|
||||
app.state.container = container
|
||||
|
||||
await initialize_app(app_config)
|
||||
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
|
||||
|
||||
await initialize_app(container.config)
|
||||
|
||||
# Cache database connections in app state for performance
|
||||
logger.info("Initializing database and caching connections...")
|
||||
engine, session_maker = await db.get_or_create_db(app_config.database_path)
|
||||
engine, session_maker = await container.init_database()
|
||||
app.state.engine = engine
|
||||
app.state.session_maker = session_maker
|
||||
logger.info("Database connections cached in app state")
|
||||
|
||||
# Start file sync if enabled
|
||||
if app_config.sync_changes and not app_config.is_test_env:
|
||||
logger.info(f"Sync changes enabled: {app_config.sync_changes}")
|
||||
# Create and start sync coordinator (lifecycle centralized in coordinator)
|
||||
sync_coordinator = container.create_sync_coordinator()
|
||||
await sync_coordinator.start()
|
||||
app.state.sync_coordinator = sync_coordinator
|
||||
|
||||
# start file sync task in background
|
||||
async def _file_sync_runner() -> None:
|
||||
await initialize_file_sync(app_config)
|
||||
|
||||
app.state.sync_task = asyncio.create_task(_file_sync_runner())
|
||||
else:
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected. Skipping file sync service.")
|
||||
else:
|
||||
logger.info("Sync changes disabled. Skipping file sync service.")
|
||||
app.state.sync_task = None
|
||||
|
||||
# proceed with startup
|
||||
# Proceed with startup
|
||||
yield
|
||||
|
||||
# Shutdown - coordinator handles clean task cancellation
|
||||
logger.info("Shutting down Basic Memory API")
|
||||
if app.state.sync_task:
|
||||
logger.info("Stopping sync...")
|
||||
app.state.sync_task.cancel() # pyright: ignore
|
||||
try:
|
||||
await app.state.sync_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Sync task cancelled successfully")
|
||||
await sync_coordinator.stop()
|
||||
|
||||
await db.shutdown_db()
|
||||
await container.shutdown_database()
|
||||
|
||||
|
||||
# Initialize FastAPI app
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""API composition root for Basic Memory.
|
||||
|
||||
This container owns reading ConfigManager and environment variables for the
|
||||
API entrypoint. Downstream modules receive config/dependencies explicitly
|
||||
rather than reading globals.
|
||||
|
||||
Design principles:
|
||||
- Only this module reads ConfigManager directly
|
||||
- Runtime mode (cloud/local/test) is resolved here
|
||||
- Factories for services are provided, not singletons
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiContainer:
|
||||
"""Composition root for the API entrypoint.
|
||||
|
||||
Holds resolved configuration and runtime context.
|
||||
Created once at app startup, then used to wire dependencies.
|
||||
"""
|
||||
|
||||
config: BasicMemoryConfig
|
||||
mode: RuntimeMode
|
||||
|
||||
# --- Database ---
|
||||
# Cached database connections (set during lifespan startup)
|
||||
engine: AsyncEngine | None = None
|
||||
session_maker: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> "ApiContainer": # pragma: no cover
|
||||
"""Create container by reading ConfigManager.
|
||||
|
||||
This is the single point where API reads global config.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
# --- Runtime Mode Properties ---
|
||||
|
||||
@property
|
||||
def should_sync_files(self) -> bool:
|
||||
"""Whether file sync should be started.
|
||||
|
||||
Sync is enabled when:
|
||||
- sync_changes is True in config
|
||||
- Not in test mode (tests manage their own sync)
|
||||
"""
|
||||
return self.config.sync_changes and not self.mode.is_test
|
||||
|
||||
@property
|
||||
def sync_skip_reason(self) -> str | None: # pragma: no cover
|
||||
"""Reason why sync is skipped, or None if sync should run.
|
||||
|
||||
Useful for logging why sync was disabled.
|
||||
"""
|
||||
if self.mode.is_test:
|
||||
return "Test environment detected"
|
||||
if not self.config.sync_changes:
|
||||
return "Sync changes disabled"
|
||||
return None
|
||||
|
||||
def create_sync_coordinator(self) -> "SyncCoordinator": # pragma: no cover
|
||||
"""Create a SyncCoordinator with this container's settings.
|
||||
|
||||
Returns:
|
||||
SyncCoordinator configured for this runtime environment
|
||||
"""
|
||||
# Deferred import to avoid circular dependency
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
return SyncCoordinator(
|
||||
config=self.config,
|
||||
should_sync=self.should_sync_files,
|
||||
skip_reason=self.sync_skip_reason,
|
||||
)
|
||||
|
||||
# --- Database Factory ---
|
||||
|
||||
async def init_database( # pragma: no cover
|
||||
self,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Initialize and cache database connections.
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, session_maker)
|
||||
"""
|
||||
engine, session_maker = await db.get_or_create_db(self.config.database_path)
|
||||
self.engine = engine
|
||||
self.session_maker = session_maker
|
||||
return engine, session_maker
|
||||
|
||||
async def shutdown_database(self) -> None: # pragma: no cover
|
||||
"""Clean up database connections."""
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
# Module-level container instance (set by lifespan)
|
||||
# This allows deps.py to access the container without reading ConfigManager
|
||||
_container: ApiContainer | None = None
|
||||
|
||||
|
||||
def get_container() -> ApiContainer:
|
||||
"""Get the current API container.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If container hasn't been initialized
|
||||
"""
|
||||
if _container is None:
|
||||
raise RuntimeError("API container not initialized. Call set_container() first.")
|
||||
return _container
|
||||
|
||||
|
||||
def set_container(container: ApiContainer) -> None:
|
||||
"""Set the API container (called by lifespan)."""
|
||||
global _container
|
||||
_container = container
|
||||
@@ -285,7 +285,7 @@ async def edit_entity_by_id(
|
||||
|
||||
# Verify entity exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
@@ -394,7 +394,7 @@ async def move_entity(
|
||||
try:
|
||||
# First, get the entity by external_id to verify it exists
|
||||
entity = await entity_repository.get_by_external_id(entity_id)
|
||||
if not entity:
|
||||
if not entity: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
||||
)
|
||||
|
||||
@@ -202,7 +202,7 @@ async def update_project_by_id(
|
||||
|
||||
# Get updated project info (use the same external_id)
|
||||
updated_project = await project_repository.get_by_external_id(project_id)
|
||||
if not updated_project:
|
||||
if not updated_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Project with external_id '{project_id}' not found after update",
|
||||
|
||||
@@ -14,7 +14,8 @@ from typing import Optional # noqa: E402
|
||||
|
||||
import typer # noqa: E402
|
||||
|
||||
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
|
||||
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
|
||||
from basic_memory.config import init_cli_logging # noqa: E402
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
|
||||
|
||||
|
||||
@@ -47,6 +48,11 @@ def app_callback(
|
||||
# Initialize logging for CLI (file only, no stdout)
|
||||
init_cli_logging()
|
||||
|
||||
# --- Composition Root ---
|
||||
# Create container and read config (single point of config access)
|
||||
container = CliContainer.create()
|
||||
set_container(container)
|
||||
|
||||
# Show telemetry notice and track CLI startup
|
||||
# Skip for 'mcp' command - it handles its own telemetry in lifespan
|
||||
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
|
||||
@@ -57,16 +63,16 @@ def app_callback(
|
||||
# Run initialization for commands that don't use the API
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
api_commands = {"mcp", "status", "sync", "project", "tool"}
|
||||
# Skip for 'reset' command - it manages its own database lifecycle
|
||||
skip_init_commands = {"mcp", "status", "sync", "project", "tool", "reset"}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
and ctx.invoked_subcommand not in api_commands
|
||||
and ctx.invoked_subcommand not in skip_init_commands
|
||||
):
|
||||
from basic_memory.services.initialization import ensure_initialization
|
||||
|
||||
app_config = ConfigManager().config
|
||||
ensure_initialization(app_config)
|
||||
ensure_initialization(container.config)
|
||||
|
||||
|
||||
## import
|
||||
|
||||
@@ -42,23 +42,45 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
|
||||
return asyncio.run(_with_cleanup())
|
||||
|
||||
|
||||
async def run_sync(project: Optional[str] = None, force_full: bool = False):
|
||||
async def run_sync(
|
||||
project: Optional[str] = None,
|
||||
force_full: bool = False,
|
||||
run_in_background: bool = True,
|
||||
):
|
||||
"""Run sync operation via API endpoint.
|
||||
|
||||
Args:
|
||||
project: Optional project name
|
||||
force_full: If True, force a full scan bypassing watermark optimization
|
||||
run_in_background: If True, return immediately; if False, wait for completion
|
||||
"""
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
url = f"{project_item.project_url}/project/sync"
|
||||
params = []
|
||||
if force_full:
|
||||
url += "?force_full=true"
|
||||
params.append("force_full=true")
|
||||
if not run_in_background:
|
||||
params.append("run_in_background=false")
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
response = await call_post(client, url)
|
||||
data = response.json()
|
||||
console.print(f"[green]{data['message']}[/green]")
|
||||
# Background mode returns {"message": "..."}, foreground returns SyncReportResponse
|
||||
if "message" in data:
|
||||
console.print(f"[green]{data['message']}[/green]")
|
||||
else:
|
||||
# Foreground mode - show summary of sync results
|
||||
total = data.get("total", 0)
|
||||
new_count = len(data.get("new", []))
|
||||
modified_count = len(data.get("modified", []))
|
||||
deleted_count = len(data.get("deleted", []))
|
||||
console.print(
|
||||
f"[green]Synced {total} files[/green] "
|
||||
f"(new: {new_count}, modified: {modified_count}, deleted: {deleted_count})"
|
||||
)
|
||||
except (ToolError, ValueError) as e:
|
||||
console.print(f"[red]Sync failed: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
"""Database management commands."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.config import ConfigManager, BasicMemoryConfig, save_basic_memory_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.repository import ProjectRepository
|
||||
from basic_memory.services.initialization import reconcile_projects_with_config
|
||||
from basic_memory.sync.sync_service import get_sync_service
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
async def _reindex_projects(app_config):
|
||||
"""Reindex all projects in a single async context.
|
||||
|
||||
This ensures all database operations use the same event loop,
|
||||
and proper cleanup happens when the function completes.
|
||||
"""
|
||||
try:
|
||||
await reconcile_projects_with_config(app_config)
|
||||
|
||||
# Get database session (migrations already run if needed)
|
||||
_, session_maker = await db.get_or_create_db(
|
||||
db_path=app_config.database_path,
|
||||
db_type=db.DatabaseType.FILESYSTEM,
|
||||
)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
projects = await project_repository.get_active_projects()
|
||||
|
||||
for project in projects:
|
||||
console.print(f" Indexing [cyan]{project.name}[/cyan]...")
|
||||
logger.info(f"Starting sync for project: {project.name}")
|
||||
sync_service = await get_sync_service(project)
|
||||
sync_dir = Path(project.path)
|
||||
await sync_service.sync(sync_dir, project_name=project.name)
|
||||
logger.info(f"Sync completed for project: {project.name}")
|
||||
finally:
|
||||
# Clean up database connections before event loop closes
|
||||
await db.shutdown_db()
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -15,30 +52,52 @@ def reset(
|
||||
reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"),
|
||||
): # pragma: no cover
|
||||
"""Reset database (drop all tables and recreate)."""
|
||||
if typer.confirm("This will delete all data in your db. Are you sure?"):
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] This only deletes the index database. "
|
||||
"Your markdown note files will not be affected.\n"
|
||||
"Use [green]bm reset --reindex[/green] to automatically rebuild the index afterward."
|
||||
)
|
||||
if typer.confirm("Reset the database index?"):
|
||||
logger.info("Resetting database...")
|
||||
config_manager = ConfigManager()
|
||||
app_config = config_manager.config
|
||||
# Get database path
|
||||
db_path = app_config.app_database_path
|
||||
|
||||
# Delete the database file if it exists
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
logger.info(f"Database file deleted: {db_path}")
|
||||
# Delete the database file and WAL files if they exist
|
||||
for suffix in ["", "-shm", "-wal"]:
|
||||
path = db_path.parent / f"{db_path.name}{suffix}"
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
logger.info(f"Deleted: {path}")
|
||||
except OSError as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot delete {path.name}: {e}\n"
|
||||
"The database may be in use by another process (e.g., MCP server).\n"
|
||||
"Please close Claude Desktop or any other Basic Memory clients and try again."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Reset project configuration
|
||||
config = BasicMemoryConfig()
|
||||
save_basic_memory_config(config_manager.config_file, config)
|
||||
logger.info("Project configuration reset to default")
|
||||
|
||||
# Create a new empty database
|
||||
asyncio.run(db.run_migrations(app_config))
|
||||
logger.info("Database reset complete")
|
||||
# Create a new empty database (preserves project configuration)
|
||||
try:
|
||||
asyncio.run(db.run_migrations(app_config))
|
||||
except OperationalError as e:
|
||||
if "disk I/O error" in str(e) or "database is locked" in str(e):
|
||||
console.print(
|
||||
"[red]Error:[/red] Cannot access database. "
|
||||
"It may be in use by another process (e.g., MCP server).\n"
|
||||
"Please close Claude Desktop or any other Basic Memory clients and try again."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
console.print("[green]Database reset complete[/green]")
|
||||
|
||||
if reindex:
|
||||
# Run database sync directly
|
||||
from basic_memory.cli.commands.command_utils import run_sync
|
||||
|
||||
logger.info("Rebuilding search index from filesystem...")
|
||||
asyncio.run(run_sync(project=None))
|
||||
projects = list(app_config.projects)
|
||||
if not projects:
|
||||
console.print("[yellow]No projects configured. Skipping reindex.[/yellow]")
|
||||
else:
|
||||
console.print(f"Rebuilding search index for {len(projects)} project(s)...")
|
||||
asyncio.run(_reindex_projects(app_config))
|
||||
console.print("[green]Reindex complete[/green]")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""CLI composition root for Basic Memory.
|
||||
|
||||
This container owns reading ConfigManager and environment variables for the
|
||||
CLI entrypoint. Downstream modules receive config/dependencies explicitly
|
||||
rather than reading globals.
|
||||
|
||||
Design principles:
|
||||
- Only this module reads ConfigManager directly
|
||||
- Runtime mode (cloud/local/test) is resolved here
|
||||
- Different CLI commands may need different initialization
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliContainer:
|
||||
"""Composition root for the CLI entrypoint.
|
||||
|
||||
Holds resolved configuration and runtime context.
|
||||
Created once at CLI startup, then used by subcommands.
|
||||
"""
|
||||
|
||||
config: BasicMemoryConfig
|
||||
mode: RuntimeMode
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> "CliContainer":
|
||||
"""Create container by reading ConfigManager.
|
||||
|
||||
This is the single point where CLI reads global config.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
# --- Runtime Mode Properties ---
|
||||
|
||||
@property
|
||||
def is_cloud_mode(self) -> bool:
|
||||
"""Whether running in cloud mode."""
|
||||
return self.mode.is_cloud
|
||||
|
||||
|
||||
# Module-level container instance (set by app callback)
|
||||
_container: CliContainer | None = None
|
||||
|
||||
|
||||
def get_container() -> CliContainer:
|
||||
"""Get the current CLI container.
|
||||
|
||||
Returns:
|
||||
The CLI container
|
||||
|
||||
Raises:
|
||||
RuntimeError: If container hasn't been initialized
|
||||
"""
|
||||
if _container is None:
|
||||
raise RuntimeError("CLI container not initialized. Call set_container() first.")
|
||||
return _container
|
||||
|
||||
|
||||
def set_container(container: CliContainer) -> None:
|
||||
"""Set the CLI container (called by app callback)."""
|
||||
global _container
|
||||
_container = container
|
||||
|
||||
|
||||
def get_or_create_container() -> CliContainer:
|
||||
"""Get existing container or create new one.
|
||||
|
||||
This is useful for CLI commands that might be called before
|
||||
the main app callback runs (e.g., eager options).
|
||||
"""
|
||||
global _container
|
||||
if _container is None:
|
||||
_container = CliContainer.create()
|
||||
return _container
|
||||
+31
-7
@@ -242,18 +242,24 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
|
||||
|
||||
|
||||
def _create_engine_and_session(
|
||||
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
||||
config: Optional[BasicMemoryConfig] = None,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Internal helper to create engine and session maker.
|
||||
|
||||
Args:
|
||||
db_path: Path to database file (used for SQLite, ignored for Postgres)
|
||||
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
|
||||
config: Optional explicit config. If not provided, reads from ConfigManager.
|
||||
Prefer passing explicitly from composition roots.
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, session_maker)
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
# Prefer explicit parameter; fall back to ConfigManager for backwards compatibility
|
||||
if config is None:
|
||||
config = ConfigManager().config
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type, config)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
|
||||
@@ -272,17 +278,29 @@ async def get_or_create_db(
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.FILESYSTEM,
|
||||
ensure_migrations: bool = True,
|
||||
config: Optional[BasicMemoryConfig] = None,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get or create database engine and session maker."""
|
||||
"""Get or create database engine and session maker.
|
||||
|
||||
Args:
|
||||
db_path: Path to database file
|
||||
db_type: Type of database
|
||||
ensure_migrations: Whether to run migrations
|
||||
config: Optional explicit config. If not provided, reads from ConfigManager.
|
||||
Prefer passing explicitly from composition roots.
|
||||
"""
|
||||
global _engine, _session_maker
|
||||
|
||||
# Prefer explicit parameter; fall back to ConfigManager for backwards compatibility
|
||||
if config is None:
|
||||
config = ConfigManager().config
|
||||
|
||||
if _engine is None:
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
|
||||
# Run migrations automatically unless explicitly disabled
|
||||
if ensure_migrations:
|
||||
app_config = ConfigManager().config
|
||||
await run_migrations(app_config, db_type)
|
||||
await run_migrations(config, db_type)
|
||||
|
||||
# These checks should never fail since we just created the engine and session maker
|
||||
# if they were None, but we'll check anyway for the type checker
|
||||
@@ -311,17 +329,23 @@ async def shutdown_db() -> None: # pragma: no cover
|
||||
async def engine_session_factory(
|
||||
db_path: Path,
|
||||
db_type: DatabaseType = DatabaseType.MEMORY,
|
||||
config: Optional[BasicMemoryConfig] = None,
|
||||
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
|
||||
"""Create engine and session factory.
|
||||
|
||||
Note: This is primarily used for testing where we want a fresh database
|
||||
for each test. For production use, use get_or_create_db() instead.
|
||||
|
||||
Args:
|
||||
db_path: Path to database file
|
||||
db_type: Type of database
|
||||
config: Optional explicit config. If not provided, reads from ConfigManager.
|
||||
"""
|
||||
|
||||
global _engine, _session_maker
|
||||
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type, config)
|
||||
|
||||
try:
|
||||
# Verify that engine and session maker are initialized
|
||||
|
||||
+14
-1012
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
"""Dependency injection for basic-memory.
|
||||
|
||||
This package provides FastAPI dependencies organized by feature:
|
||||
- config: Application configuration
|
||||
- db: Database/session management
|
||||
- projects: Project resolution and config
|
||||
- repositories: Data access layer
|
||||
- services: Business logic layer
|
||||
- importers: Import functionality
|
||||
|
||||
For backwards compatibility, all dependencies are re-exported from this module.
|
||||
New code should import from specific submodules to reduce coupling.
|
||||
"""
|
||||
|
||||
# Re-export everything for backwards compatibility
|
||||
# Eventually, callers should import from specific submodules
|
||||
|
||||
from basic_memory.deps.config import (
|
||||
get_app_config,
|
||||
AppConfigDep,
|
||||
)
|
||||
|
||||
from basic_memory.deps.db import (
|
||||
get_engine_factory,
|
||||
EngineFactoryDep,
|
||||
get_session_maker,
|
||||
SessionMakerDep,
|
||||
)
|
||||
|
||||
from basic_memory.deps.projects import (
|
||||
get_project_repository,
|
||||
ProjectRepositoryDep,
|
||||
ProjectPathDep,
|
||||
get_project_id,
|
||||
ProjectIdDep,
|
||||
get_project_config,
|
||||
ProjectConfigDep,
|
||||
validate_project_id,
|
||||
ProjectIdPathDep,
|
||||
get_project_config_v2,
|
||||
ProjectConfigV2Dep,
|
||||
validate_project_external_id,
|
||||
ProjectExternalIdPathDep,
|
||||
get_project_config_v2_external,
|
||||
ProjectConfigV2ExternalDep,
|
||||
)
|
||||
|
||||
from basic_memory.deps.repositories import (
|
||||
get_entity_repository,
|
||||
EntityRepositoryDep,
|
||||
get_entity_repository_v2,
|
||||
EntityRepositoryV2Dep,
|
||||
get_entity_repository_v2_external,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
get_observation_repository,
|
||||
ObservationRepositoryDep,
|
||||
get_observation_repository_v2,
|
||||
ObservationRepositoryV2Dep,
|
||||
get_observation_repository_v2_external,
|
||||
ObservationRepositoryV2ExternalDep,
|
||||
get_relation_repository,
|
||||
RelationRepositoryDep,
|
||||
get_relation_repository_v2,
|
||||
RelationRepositoryV2Dep,
|
||||
get_relation_repository_v2_external,
|
||||
RelationRepositoryV2ExternalDep,
|
||||
get_search_repository,
|
||||
SearchRepositoryDep,
|
||||
get_search_repository_v2,
|
||||
SearchRepositoryV2Dep,
|
||||
get_search_repository_v2_external,
|
||||
SearchRepositoryV2ExternalDep,
|
||||
)
|
||||
|
||||
from basic_memory.deps.services import (
|
||||
get_entity_parser,
|
||||
EntityParserDep,
|
||||
get_entity_parser_v2,
|
||||
EntityParserV2Dep,
|
||||
get_entity_parser_v2_external,
|
||||
EntityParserV2ExternalDep,
|
||||
get_markdown_processor,
|
||||
MarkdownProcessorDep,
|
||||
get_markdown_processor_v2,
|
||||
MarkdownProcessorV2Dep,
|
||||
get_markdown_processor_v2_external,
|
||||
MarkdownProcessorV2ExternalDep,
|
||||
get_file_service,
|
||||
FileServiceDep,
|
||||
get_file_service_v2,
|
||||
FileServiceV2Dep,
|
||||
get_file_service_v2_external,
|
||||
FileServiceV2ExternalDep,
|
||||
get_search_service,
|
||||
SearchServiceDep,
|
||||
get_search_service_v2,
|
||||
SearchServiceV2Dep,
|
||||
get_search_service_v2_external,
|
||||
SearchServiceV2ExternalDep,
|
||||
get_link_resolver,
|
||||
LinkResolverDep,
|
||||
get_link_resolver_v2,
|
||||
LinkResolverV2Dep,
|
||||
get_link_resolver_v2_external,
|
||||
LinkResolverV2ExternalDep,
|
||||
get_entity_service,
|
||||
EntityServiceDep,
|
||||
get_entity_service_v2,
|
||||
EntityServiceV2Dep,
|
||||
get_entity_service_v2_external,
|
||||
EntityServiceV2ExternalDep,
|
||||
get_context_service,
|
||||
ContextServiceDep,
|
||||
get_context_service_v2,
|
||||
ContextServiceV2Dep,
|
||||
get_context_service_v2_external,
|
||||
ContextServiceV2ExternalDep,
|
||||
get_sync_service,
|
||||
SyncServiceDep,
|
||||
get_sync_service_v2,
|
||||
SyncServiceV2Dep,
|
||||
get_sync_service_v2_external,
|
||||
SyncServiceV2ExternalDep,
|
||||
get_project_service,
|
||||
ProjectServiceDep,
|
||||
get_directory_service,
|
||||
DirectoryServiceDep,
|
||||
get_directory_service_v2,
|
||||
DirectoryServiceV2Dep,
|
||||
get_directory_service_v2_external,
|
||||
DirectoryServiceV2ExternalDep,
|
||||
)
|
||||
|
||||
from basic_memory.deps.importers import (
|
||||
get_chatgpt_importer,
|
||||
ChatGPTImporterDep,
|
||||
get_chatgpt_importer_v2,
|
||||
ChatGPTImporterV2Dep,
|
||||
get_chatgpt_importer_v2_external,
|
||||
ChatGPTImporterV2ExternalDep,
|
||||
get_claude_conversations_importer,
|
||||
ClaudeConversationsImporterDep,
|
||||
get_claude_conversations_importer_v2,
|
||||
ClaudeConversationsImporterV2Dep,
|
||||
get_claude_conversations_importer_v2_external,
|
||||
ClaudeConversationsImporterV2ExternalDep,
|
||||
get_claude_projects_importer,
|
||||
ClaudeProjectsImporterDep,
|
||||
get_claude_projects_importer_v2,
|
||||
ClaudeProjectsImporterV2Dep,
|
||||
get_claude_projects_importer_v2_external,
|
||||
ClaudeProjectsImporterV2ExternalDep,
|
||||
get_memory_json_importer,
|
||||
MemoryJsonImporterDep,
|
||||
get_memory_json_importer_v2,
|
||||
MemoryJsonImporterV2Dep,
|
||||
get_memory_json_importer_v2_external,
|
||||
MemoryJsonImporterV2ExternalDep,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"get_app_config",
|
||||
"AppConfigDep",
|
||||
# Database
|
||||
"get_engine_factory",
|
||||
"EngineFactoryDep",
|
||||
"get_session_maker",
|
||||
"SessionMakerDep",
|
||||
# Projects
|
||||
"get_project_repository",
|
||||
"ProjectRepositoryDep",
|
||||
"ProjectPathDep",
|
||||
"get_project_id",
|
||||
"ProjectIdDep",
|
||||
"get_project_config",
|
||||
"ProjectConfigDep",
|
||||
"validate_project_id",
|
||||
"ProjectIdPathDep",
|
||||
"get_project_config_v2",
|
||||
"ProjectConfigV2Dep",
|
||||
"validate_project_external_id",
|
||||
"ProjectExternalIdPathDep",
|
||||
"get_project_config_v2_external",
|
||||
"ProjectConfigV2ExternalDep",
|
||||
# Repositories
|
||||
"get_entity_repository",
|
||||
"EntityRepositoryDep",
|
||||
"get_entity_repository_v2",
|
||||
"EntityRepositoryV2Dep",
|
||||
"get_entity_repository_v2_external",
|
||||
"EntityRepositoryV2ExternalDep",
|
||||
"get_observation_repository",
|
||||
"ObservationRepositoryDep",
|
||||
"get_observation_repository_v2",
|
||||
"ObservationRepositoryV2Dep",
|
||||
"get_observation_repository_v2_external",
|
||||
"ObservationRepositoryV2ExternalDep",
|
||||
"get_relation_repository",
|
||||
"RelationRepositoryDep",
|
||||
"get_relation_repository_v2",
|
||||
"RelationRepositoryV2Dep",
|
||||
"get_relation_repository_v2_external",
|
||||
"RelationRepositoryV2ExternalDep",
|
||||
"get_search_repository",
|
||||
"SearchRepositoryDep",
|
||||
"get_search_repository_v2",
|
||||
"SearchRepositoryV2Dep",
|
||||
"get_search_repository_v2_external",
|
||||
"SearchRepositoryV2ExternalDep",
|
||||
# Services
|
||||
"get_entity_parser",
|
||||
"EntityParserDep",
|
||||
"get_entity_parser_v2",
|
||||
"EntityParserV2Dep",
|
||||
"get_entity_parser_v2_external",
|
||||
"EntityParserV2ExternalDep",
|
||||
"get_markdown_processor",
|
||||
"MarkdownProcessorDep",
|
||||
"get_markdown_processor_v2",
|
||||
"MarkdownProcessorV2Dep",
|
||||
"get_markdown_processor_v2_external",
|
||||
"MarkdownProcessorV2ExternalDep",
|
||||
"get_file_service",
|
||||
"FileServiceDep",
|
||||
"get_file_service_v2",
|
||||
"FileServiceV2Dep",
|
||||
"get_file_service_v2_external",
|
||||
"FileServiceV2ExternalDep",
|
||||
"get_search_service",
|
||||
"SearchServiceDep",
|
||||
"get_search_service_v2",
|
||||
"SearchServiceV2Dep",
|
||||
"get_search_service_v2_external",
|
||||
"SearchServiceV2ExternalDep",
|
||||
"get_link_resolver",
|
||||
"LinkResolverDep",
|
||||
"get_link_resolver_v2",
|
||||
"LinkResolverV2Dep",
|
||||
"get_link_resolver_v2_external",
|
||||
"LinkResolverV2ExternalDep",
|
||||
"get_entity_service",
|
||||
"EntityServiceDep",
|
||||
"get_entity_service_v2",
|
||||
"EntityServiceV2Dep",
|
||||
"get_entity_service_v2_external",
|
||||
"EntityServiceV2ExternalDep",
|
||||
"get_context_service",
|
||||
"ContextServiceDep",
|
||||
"get_context_service_v2",
|
||||
"ContextServiceV2Dep",
|
||||
"get_context_service_v2_external",
|
||||
"ContextServiceV2ExternalDep",
|
||||
"get_sync_service",
|
||||
"SyncServiceDep",
|
||||
"get_sync_service_v2",
|
||||
"SyncServiceV2Dep",
|
||||
"get_sync_service_v2_external",
|
||||
"SyncServiceV2ExternalDep",
|
||||
"get_project_service",
|
||||
"ProjectServiceDep",
|
||||
"get_directory_service",
|
||||
"DirectoryServiceDep",
|
||||
"get_directory_service_v2",
|
||||
"DirectoryServiceV2Dep",
|
||||
"get_directory_service_v2_external",
|
||||
"DirectoryServiceV2ExternalDep",
|
||||
# Importers
|
||||
"get_chatgpt_importer",
|
||||
"ChatGPTImporterDep",
|
||||
"get_chatgpt_importer_v2",
|
||||
"ChatGPTImporterV2Dep",
|
||||
"get_chatgpt_importer_v2_external",
|
||||
"ChatGPTImporterV2ExternalDep",
|
||||
"get_claude_conversations_importer",
|
||||
"ClaudeConversationsImporterDep",
|
||||
"get_claude_conversations_importer_v2",
|
||||
"ClaudeConversationsImporterV2Dep",
|
||||
"get_claude_conversations_importer_v2_external",
|
||||
"ClaudeConversationsImporterV2ExternalDep",
|
||||
"get_claude_projects_importer",
|
||||
"ClaudeProjectsImporterDep",
|
||||
"get_claude_projects_importer_v2",
|
||||
"ClaudeProjectsImporterV2Dep",
|
||||
"get_claude_projects_importer_v2_external",
|
||||
"ClaudeProjectsImporterV2ExternalDep",
|
||||
"get_memory_json_importer",
|
||||
"MemoryJsonImporterDep",
|
||||
"get_memory_json_importer_v2",
|
||||
"MemoryJsonImporterV2Dep",
|
||||
"get_memory_json_importer_v2_external",
|
||||
"MemoryJsonImporterV2ExternalDep",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Configuration dependency injection for basic-memory.
|
||||
|
||||
This module provides configuration-related dependencies.
|
||||
Note: Long-term goal is to minimize direct ConfigManager access
|
||||
and inject config from composition roots instead.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
|
||||
|
||||
def get_app_config() -> BasicMemoryConfig: # pragma: no cover
|
||||
"""Get the application configuration.
|
||||
|
||||
Note: This is a transitional dependency. The goal is for composition roots
|
||||
to read ConfigManager and inject config explicitly. During migration,
|
||||
this provides the same behavior as before.
|
||||
"""
|
||||
app_config = ConfigManager().config
|
||||
return app_config
|
||||
|
||||
|
||||
AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Database dependency injection for basic-memory.
|
||||
|
||||
This module provides database-related dependencies:
|
||||
- Engine and session maker factories
|
||||
- Session dependencies for request handling
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from loguru import logger
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
)
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.deps.config import get_app_config
|
||||
|
||||
|
||||
async def get_engine_factory(
|
||||
request: Request,
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
|
||||
"""Get cached engine and session maker from app state.
|
||||
|
||||
For API requests, returns cached connections from app.state for optimal performance.
|
||||
For non-API contexts (CLI), falls back to direct database connection.
|
||||
"""
|
||||
# Try to get cached connections from app state (API context)
|
||||
if (
|
||||
hasattr(request, "app")
|
||||
and hasattr(request.app.state, "engine")
|
||||
and hasattr(request.app.state, "session_maker")
|
||||
):
|
||||
return request.app.state.engine, request.app.state.session_maker
|
||||
|
||||
# Fallback for non-API contexts (CLI)
|
||||
logger.debug("Using fallback database connection for non-API context")
|
||||
app_config = get_app_config()
|
||||
engine, session_maker = await db.get_or_create_db(app_config.database_path)
|
||||
return engine, session_maker
|
||||
|
||||
|
||||
EngineFactoryDep = Annotated[
|
||||
tuple[AsyncEngine, async_sessionmaker[AsyncSession]], Depends(get_engine_factory)
|
||||
]
|
||||
|
||||
|
||||
async def get_session_maker(engine_factory: EngineFactoryDep) -> async_sessionmaker[AsyncSession]:
|
||||
"""Get session maker."""
|
||||
_, session_maker = engine_factory
|
||||
return session_maker
|
||||
|
||||
|
||||
SessionMakerDep = Annotated[async_sessionmaker, Depends(get_session_maker)]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Importer dependency injection for basic-memory.
|
||||
|
||||
This module provides importer dependencies:
|
||||
- ChatGPTImporter
|
||||
- ClaudeConversationsImporter
|
||||
- ClaudeProjectsImporter
|
||||
- MemoryJsonImporter
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from basic_memory.deps.projects import (
|
||||
ProjectConfigDep,
|
||||
ProjectConfigV2Dep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
)
|
||||
from basic_memory.deps.services import (
|
||||
FileServiceDep,
|
||||
FileServiceV2Dep,
|
||||
FileServiceV2ExternalDep,
|
||||
MarkdownProcessorDep,
|
||||
MarkdownProcessorV2Dep,
|
||||
MarkdownProcessorV2ExternalDep,
|
||||
)
|
||||
from basic_memory.importers import (
|
||||
ChatGPTImporter,
|
||||
ClaudeConversationsImporter,
|
||||
ClaudeProjectsImporter,
|
||||
MemoryJsonImporter,
|
||||
)
|
||||
|
||||
|
||||
# --- ChatGPT Importer ---
|
||||
|
||||
|
||||
async def get_chatgpt_importer(
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer)]
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2( # pragma: no cover
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 external_id dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ChatGPTImporterV2ExternalDep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2_external)]
|
||||
|
||||
|
||||
# --- Claude Conversations Importer ---
|
||||
|
||||
|
||||
async def get_claude_conversations_importer(
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterDep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2( # pragma: no cover
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 external_id dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2ExternalDep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2_external)
|
||||
]
|
||||
|
||||
|
||||
# --- Claude Projects Importer ---
|
||||
|
||||
|
||||
async def get_claude_projects_importer(
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterDep = Annotated[ClaudeProjectsImporter, Depends(get_claude_projects_importer)]
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2( # pragma: no cover
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 external_id dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2ExternalDep = Annotated[
|
||||
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2_external)
|
||||
]
|
||||
|
||||
|
||||
# --- Memory JSON Importer ---
|
||||
|
||||
|
||||
async def get_memory_json_importer(
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2( # pragma: no cover
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 external_id dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor, file_service)
|
||||
|
||||
|
||||
MemoryJsonImporterV2ExternalDep = Annotated[
|
||||
MemoryJsonImporter, Depends(get_memory_json_importer_v2_external)
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Project dependency injection for basic-memory.
|
||||
|
||||
This module provides project-related dependencies:
|
||||
- Project path extraction from URL
|
||||
- Project config resolution
|
||||
- Project ID validation
|
||||
- Project repository
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Path, status
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.deps.db import SessionMakerDep
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
# --- Project Repository ---
|
||||
|
||||
|
||||
async def get_project_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
) -> ProjectRepository:
|
||||
"""Get the project repository."""
|
||||
return ProjectRepository(session_maker)
|
||||
|
||||
|
||||
ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_repository)]
|
||||
|
||||
|
||||
# --- Path Extraction ---
|
||||
|
||||
# V1 API: Project name from URL path
|
||||
ProjectPathDep = Annotated[str, Path()]
|
||||
|
||||
|
||||
# --- Project ID Resolution (V1 API) ---
|
||||
|
||||
|
||||
async def get_project_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project: ProjectPathDep,
|
||||
) -> int:
|
||||
"""Get the current project ID from request state.
|
||||
|
||||
When using sub-applications with /{project} mounting, the project value
|
||||
is stored in request.state by middleware.
|
||||
|
||||
Args:
|
||||
project_repository: Repository for project operations
|
||||
project: The project name from URL path
|
||||
|
||||
Returns:
|
||||
The resolved project ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
# Convert project name to permalink for lookup
|
||||
project_permalink = generate_permalink(str(project))
|
||||
project_obj = await project_repository.get_by_permalink(project_permalink)
|
||||
if project_obj:
|
||||
return project_obj.id
|
||||
|
||||
# Try by name if permalink lookup fails
|
||||
project_obj = await project_repository.get_by_name(str(project)) # pragma: no cover
|
||||
if project_obj: # pragma: no cover
|
||||
return project_obj.id
|
||||
|
||||
# Not found
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectIdDep = Annotated[int, Depends(get_project_id)]
|
||||
|
||||
|
||||
# --- Project Config Resolution (V1 API) ---
|
||||
|
||||
|
||||
async def get_project_config(
|
||||
project: ProjectPathDep, project_repository: ProjectRepositoryDep
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the current project referenced from request state.
|
||||
|
||||
Args:
|
||||
project: The project name from URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
# Convert project name to permalink for lookup
|
||||
project_permalink = generate_permalink(str(project))
|
||||
project_obj = await project_repository.get_by_permalink(project_permalink)
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project '{project}' not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)]
|
||||
|
||||
|
||||
# --- V2 API: Integer Project ID from Path ---
|
||||
|
||||
|
||||
async def validate_project_id(
|
||||
project_id: int,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> int:
|
||||
"""Validate that a numeric project ID exists in the database.
|
||||
|
||||
This is used for v2 API endpoints that take project IDs as integers in the path.
|
||||
The project_id parameter will be automatically extracted from the URL path by FastAPI.
|
||||
|
||||
Args:
|
||||
project_id: The numeric project ID from the URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The validated project ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If project with that ID is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if not project_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with ID {project_id} not found.",
|
||||
)
|
||||
return project_id
|
||||
|
||||
|
||||
ProjectIdPathDep = Annotated[int, Depends(validate_project_id)]
|
||||
|
||||
|
||||
async def get_project_config_v2(
|
||||
project_id: ProjectIdPathDep, project_repository: ProjectRepositoryDep
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the project config for v2 API (uses integer project_id from path).
|
||||
|
||||
Args:
|
||||
project_id: The validated numeric project ID from the URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found (this should not happen since ProjectIdPathDep already validates existence)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)]
|
||||
|
||||
|
||||
# --- V2 API: External UUID Project ID from Path ---
|
||||
|
||||
|
||||
async def validate_project_external_id(
|
||||
project_id: str,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> int:
|
||||
"""Validate that a project external_id (UUID) exists in the database.
|
||||
|
||||
This is used for v2 API endpoints that take project external_ids as strings in the path.
|
||||
The project_id parameter will be automatically extracted from the URL path by FastAPI.
|
||||
|
||||
Args:
|
||||
project_id: The external UUID from the URL path (named project_id for URL consistency)
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The internal numeric project ID (for use by repositories)
|
||||
|
||||
Raises:
|
||||
HTTPException: If project with that external_id is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_external_id(project_id)
|
||||
if not project_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with external_id '{project_id}' not found.",
|
||||
)
|
||||
return project_obj.id
|
||||
|
||||
|
||||
ProjectExternalIdPathDep = Annotated[int, Depends(validate_project_external_id)]
|
||||
|
||||
|
||||
async def get_project_config_v2_external(
|
||||
project_id: ProjectExternalIdPathDep, project_repository: ProjectRepositoryDep
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the project config for v2 API (uses external_id UUID from path).
|
||||
|
||||
Args:
|
||||
project_id: The internal project ID resolved from external_id
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found (this should not happen since ProjectExternalIdPathDep already validates)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2ExternalDep = Annotated[
|
||||
ProjectConfig, Depends(get_project_config_v2_external)
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Repository dependency injection for basic-memory.
|
||||
|
||||
This module provides repository dependencies:
|
||||
- EntityRepository
|
||||
- ObservationRepository
|
||||
- RelationRepository
|
||||
- SearchRepository
|
||||
|
||||
Each repository is scoped to a project ID from the request.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from basic_memory.deps.db import SessionMakerDep
|
||||
from basic_memory.deps.projects import (
|
||||
ProjectIdDep,
|
||||
ProjectIdPathDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, create_search_repository
|
||||
|
||||
|
||||
# --- Entity Repository ---
|
||||
|
||||
|
||||
async def get_entity_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for the current project."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
|
||||
|
||||
|
||||
async def get_entity_repository_v2( # pragma: no cover
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for v2 API (uses integer project_id from path)."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
|
||||
|
||||
|
||||
async def get_entity_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for v2 API (uses external_id from path)."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryV2ExternalDep = Annotated[EntityRepository, Depends(get_entity_repository_v2_external)]
|
||||
|
||||
|
||||
# --- Observation Repository ---
|
||||
|
||||
|
||||
async def get_observation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for the current project."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
|
||||
|
||||
|
||||
async def get_observation_repository_v2( # pragma: no cover
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for v2 API."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryV2Dep = Annotated[
|
||||
ObservationRepository, Depends(get_observation_repository_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_observation_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for v2 API (uses external_id)."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryV2ExternalDep = Annotated[
|
||||
ObservationRepository, Depends(get_observation_repository_v2_external)
|
||||
]
|
||||
|
||||
|
||||
# --- Relation Repository ---
|
||||
|
||||
|
||||
async def get_relation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for the current project."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
|
||||
|
||||
|
||||
async def get_relation_repository_v2( # pragma: no cover
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for v2 API."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
|
||||
|
||||
|
||||
async def get_relation_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for v2 API (uses external_id)."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryV2ExternalDep = Annotated[
|
||||
RelationRepository, Depends(get_relation_repository_v2_external)
|
||||
]
|
||||
|
||||
|
||||
# --- Search Repository ---
|
||||
|
||||
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a backend-specific SearchRepository instance for the current project.
|
||||
|
||||
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
|
||||
based on database backend configuration.
|
||||
"""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
||||
|
||||
|
||||
async def get_search_repository_v2( # pragma: no cover
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API."""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
|
||||
|
||||
|
||||
async def get_search_repository_v2_external(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API (uses external_id)."""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2ExternalDep = Annotated[SearchRepository, Depends(get_search_repository_v2_external)]
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Service dependency injection for basic-memory.
|
||||
|
||||
This module provides service-layer dependencies:
|
||||
- EntityParser, MarkdownProcessor
|
||||
- FileService, EntityService
|
||||
- SearchService, LinkResolver, ContextService
|
||||
- SyncService, ProjectService, DirectoryService
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps.config import AppConfigDep
|
||||
from basic_memory.deps.projects import (
|
||||
ProjectConfigDep,
|
||||
ProjectConfigV2Dep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
ProjectRepositoryDep,
|
||||
)
|
||||
from basic_memory.deps.repositories import (
|
||||
EntityRepositoryDep,
|
||||
EntityRepositoryV2Dep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
ObservationRepositoryDep,
|
||||
ObservationRepositoryV2Dep,
|
||||
ObservationRepositoryV2ExternalDep,
|
||||
RelationRepositoryDep,
|
||||
RelationRepositoryV2Dep,
|
||||
RelationRepositoryV2ExternalDep,
|
||||
SearchRepositoryDep,
|
||||
SearchRepositoryV2Dep,
|
||||
SearchRepositoryV2ExternalDep,
|
||||
)
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.services import EntityService, ProjectService
|
||||
from basic_memory.services.context_service import ContextService
|
||||
from basic_memory.services.directory_service import DirectoryService
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
|
||||
|
||||
# --- Entity Parser ---
|
||||
|
||||
|
||||
async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
|
||||
|
||||
|
||||
async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityParser: # pragma: no cover
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
async def get_entity_parser_v2_external(project_config: ProjectConfigV2ExternalDep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserV2ExternalDep = Annotated["EntityParser", Depends(get_entity_parser_v2_external)]
|
||||
|
||||
|
||||
# --- Markdown Processor ---
|
||||
|
||||
|
||||
async def get_markdown_processor(
|
||||
entity_parser: EntityParserDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2( # pragma: no cover
|
||||
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2_external(
|
||||
entity_parser: EntityParserV2ExternalDep, app_config: AppConfigDep
|
||||
) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
|
||||
|
||||
MarkdownProcessorV2ExternalDep = Annotated[
|
||||
MarkdownProcessor, Depends(get_markdown_processor_v2_external)
|
||||
]
|
||||
|
||||
|
||||
# --- File Service ---
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep,
|
||||
markdown_processor: MarkdownProcessorDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
|
||||
)
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2( # pragma: no cover
|
||||
project_config: ProjectConfigV2Dep,
|
||||
markdown_processor: MarkdownProcessorV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceV2Dep = Annotated[FileService, Depends(get_file_service_v2)]
|
||||
|
||||
|
||||
async def get_file_service_v2_external(
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
markdown_processor: MarkdownProcessorV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> FileService:
|
||||
file_service = FileService(project_config.home, markdown_processor, app_config=app_config)
|
||||
logger.debug(
|
||||
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceV2ExternalDep = Annotated[FileService, Depends(get_file_service_v2_external)]
|
||||
|
||||
|
||||
# --- Search Service ---
|
||||
|
||||
|
||||
async def get_search_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService with dependencies."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
||||
|
||||
|
||||
async def get_search_service_v2( # pragma: no cover
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService for v2 API."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceV2Dep = Annotated[SearchService, Depends(get_search_service_v2)]
|
||||
|
||||
|
||||
async def get_search_service_v2_external(
|
||||
search_repository: SearchRepositoryV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService for v2 API (uses external_id)."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceV2ExternalDep = Annotated[SearchService, Depends(get_search_service_v2_external)]
|
||||
|
||||
|
||||
# --- Link Resolver ---
|
||||
|
||||
|
||||
async def get_link_resolver(
|
||||
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
||||
|
||||
|
||||
async def get_link_resolver_v2( # pragma: no cover
|
||||
entity_repository: EntityRepositoryV2Dep, search_service: SearchServiceV2Dep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverV2Dep = Annotated[LinkResolver, Depends(get_link_resolver_v2)]
|
||||
|
||||
|
||||
async def get_link_resolver_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep, search_service: SearchServiceV2ExternalDep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverV2ExternalDep = Annotated[LinkResolver, Depends(get_link_resolver_v2_external)]
|
||||
|
||||
|
||||
# --- Entity Service ---
|
||||
|
||||
|
||||
async def get_entity_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
relation_repository: RelationRepositoryDep,
|
||||
entity_parser: EntityParserDep,
|
||||
file_service: FileServiceDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service: SearchServiceDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService with repository."""
|
||||
return EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
|
||||
|
||||
|
||||
async def get_entity_service_v2( # pragma: no cover
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
link_resolver: LinkResolverV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API."""
|
||||
return EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceV2Dep = Annotated[EntityService, Depends(get_entity_service_v2)]
|
||||
|
||||
|
||||
async def get_entity_service_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
observation_repository: ObservationRepositoryV2ExternalDep,
|
||||
relation_repository: RelationRepositoryV2ExternalDep,
|
||||
entity_parser: EntityParserV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
link_resolver: LinkResolverV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API (uses external_id)."""
|
||||
return EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceV2ExternalDep = Annotated[EntityService, Depends(get_entity_service_v2_external)]
|
||||
|
||||
|
||||
# --- Context Service ---
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
) -> ContextService:
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
||||
|
||||
|
||||
async def get_context_service_v2( # pragma: no cover
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
) -> ContextService:
|
||||
"""Create ContextService for v2 API."""
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceV2Dep = Annotated[ContextService, Depends(get_context_service_v2)]
|
||||
|
||||
|
||||
async def get_context_service_v2_external(
|
||||
search_repository: SearchRepositoryV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
observation_repository: ObservationRepositoryV2ExternalDep,
|
||||
) -> ContextService:
|
||||
"""Create ContextService for v2 API (uses external_id)."""
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceV2ExternalDep = Annotated[ContextService, Depends(get_context_service_v2_external)]
|
||||
|
||||
|
||||
# --- Sync Service ---
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceDep,
|
||||
entity_parser: EntityParserDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
relation_repository: RelationRepositoryDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
project_repository=project_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||
|
||||
|
||||
async def get_sync_service_v2(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""Create SyncService for v2 API."""
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
project_repository=project_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceV2Dep = Annotated[SyncService, Depends(get_sync_service_v2)]
|
||||
|
||||
|
||||
async def get_sync_service_v2_external(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
entity_parser: EntityParserV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
relation_repository: RelationRepositoryV2ExternalDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""Create SyncService for v2 API (uses external_id)."""
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
project_repository=project_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceV2ExternalDep = Annotated[SyncService, Depends(get_sync_service_v2_external)]
|
||||
|
||||
|
||||
# --- Project Service ---
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectService:
|
||||
"""Create ProjectService with repository."""
|
||||
return ProjectService(repository=project_repository)
|
||||
|
||||
|
||||
ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
|
||||
|
||||
|
||||
# --- Directory Service ---
|
||||
|
||||
|
||||
async def get_directory_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService with dependencies."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
|
||||
|
||||
|
||||
async def get_directory_service_v2( # pragma: no cover
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService for v2 API (uses integer project_id from path)."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceV2Dep = Annotated[DirectoryService, Depends(get_directory_service_v2)]
|
||||
|
||||
|
||||
async def get_directory_service_v2_external(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService for v2 API (uses external_id from path)."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceV2ExternalDep = Annotated[DirectoryService, Depends(get_directory_service_v2_external)]
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing ChatGPT conversations."""
|
||||
|
||||
def handle_error(
|
||||
def handle_error( # pragma: no cover
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ChatImportResult:
|
||||
"""Return a failed ChatImportResult with an error message."""
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""Service for importing Claude conversations."""
|
||||
|
||||
def handle_error(
|
||||
def handle_error( # pragma: no cover
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ChatImportResult:
|
||||
"""Return a failed ChatImportResult with an error message."""
|
||||
|
||||
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"""Service for importing Claude projects."""
|
||||
|
||||
def handle_error(
|
||||
def handle_error( # pragma: no cover
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> ProjectImportResult:
|
||||
"""Return a failed ProjectImportResult with an error message."""
|
||||
|
||||
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
"""Service for importing memory.json format data."""
|
||||
|
||||
def handle_error(
|
||||
def handle_error( # pragma: no cover
|
||||
self, message: str, error: Optional[Exception] = None
|
||||
) -> EntityImportResult:
|
||||
"""Return a failed EntityImportResult with an error message."""
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Typed internal API clients for MCP tools.
|
||||
|
||||
These clients encapsulate API paths, error handling, and response validation.
|
||||
MCP tools become thin adapters that call these clients and format results.
|
||||
|
||||
Usage:
|
||||
from basic_memory.mcp.clients import KnowledgeClient, SearchClient
|
||||
|
||||
async with get_client() as http_client:
|
||||
knowledge = KnowledgeClient(http_client, project_id)
|
||||
entity = await knowledge.create_entity(entity_data)
|
||||
"""
|
||||
|
||||
from basic_memory.mcp.clients.knowledge import KnowledgeClient
|
||||
from basic_memory.mcp.clients.search import SearchClient
|
||||
from basic_memory.mcp.clients.memory import MemoryClient
|
||||
from basic_memory.mcp.clients.directory import DirectoryClient
|
||||
from basic_memory.mcp.clients.resource import ResourceClient
|
||||
from basic_memory.mcp.clients.project import ProjectClient
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeClient",
|
||||
"SearchClient",
|
||||
"MemoryClient",
|
||||
"DirectoryClient",
|
||||
"ResourceClient",
|
||||
"ProjectClient",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Typed client for directory API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/directory/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional, Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
|
||||
class DirectoryClient:
|
||||
"""Typed client for directory listing operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/directory/*
|
||||
- Response validation
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = DirectoryClient(http_client, project_id)
|
||||
nodes = await client.list("/", depth=2)
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the directory client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/directory"
|
||||
|
||||
async def list(
|
||||
self,
|
||||
dir_name: str = "/",
|
||||
*,
|
||||
depth: int = 1,
|
||||
file_name_glob: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
dir_name: Directory path to list (default: root)
|
||||
depth: How deep to traverse (default: 1)
|
||||
file_name_glob: Optional glob pattern to filter files
|
||||
|
||||
Returns:
|
||||
List of directory nodes with their contents
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params: dict = {
|
||||
"dir_name": dir_name,
|
||||
"depth": depth,
|
||||
}
|
||||
if file_name_glob:
|
||||
params["file_name_glob"] = file_name_glob
|
||||
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/list",
|
||||
params=params,
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Typed client for knowledge/entity API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/knowledge/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
|
||||
from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
|
||||
|
||||
|
||||
class KnowledgeClient:
|
||||
"""Typed client for knowledge graph entity operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/knowledge/*
|
||||
- Response validation via Pydantic models
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = KnowledgeClient(http_client, project_id)
|
||||
entity = await client.create_entity(entity_data)
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the knowledge client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/knowledge"
|
||||
|
||||
# --- Entity CRUD Operations ---
|
||||
|
||||
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
entity_data: Entity data including title, content, folder, etc.
|
||||
|
||||
Returns:
|
||||
EntityResponse with created entity details
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities",
|
||||
json=entity_data,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
|
||||
"""Update an existing entity (full replacement).
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
entity_data: Complete entity data for replacement
|
||||
|
||||
Returns:
|
||||
EntityResponse with updated entity details
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_put(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=entity_data,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def get_entity(self, entity_id: str) -> EntityResponse:
|
||||
"""Get an entity by ID.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
|
||||
Returns:
|
||||
EntityResponse with entity details
|
||||
|
||||
Raises:
|
||||
ToolError: If the entity is not found or request fails
|
||||
"""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
|
||||
"""Partially update an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
patch_data: Partial entity data to update
|
||||
|
||||
Returns:
|
||||
EntityResponse with updated entity details
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_patch(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=patch_data,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def delete_entity(self, entity_id: str) -> DeleteEntitiesResponse:
|
||||
"""Delete an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
|
||||
Returns:
|
||||
DeleteEntitiesResponse confirming deletion
|
||||
|
||||
Raises:
|
||||
ToolError: If the entity is not found or request fails
|
||||
"""
|
||||
response = await call_delete(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
)
|
||||
return DeleteEntitiesResponse.model_validate(response.json())
|
||||
|
||||
async def move_entity(self, entity_id: str, destination_path: str) -> EntityResponse:
|
||||
"""Move an entity to a new location.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
destination_path: New file path for the entity
|
||||
|
||||
Returns:
|
||||
EntityResponse with updated entity details
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_put(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}/move",
|
||||
json={"destination_path": destination_path},
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
# --- Resolution ---
|
||||
|
||||
async def resolve_entity(self, identifier: str) -> str:
|
||||
"""Resolve a string identifier to an entity external_id.
|
||||
|
||||
Args:
|
||||
identifier: The identifier to resolve (permalink, title, or path)
|
||||
|
||||
Returns:
|
||||
The resolved entity external_id (UUID)
|
||||
|
||||
Raises:
|
||||
ToolError: If the identifier cannot be resolved
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/resolve",
|
||||
json={"identifier": identifier},
|
||||
)
|
||||
data = response.json()
|
||||
return data["external_id"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Typed client for memory/context API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/memory/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
|
||||
class MemoryClient:
|
||||
"""Typed client for memory context operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/memory/*
|
||||
- Response validation via Pydantic models
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = MemoryClient(http_client, project_id)
|
||||
context = await client.build_context("memory://specs/search")
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the memory client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/memory"
|
||||
|
||||
async def build_context(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
depth: int = 1,
|
||||
timeframe: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Build context from a memory path.
|
||||
|
||||
Args:
|
||||
path: The path to build context for (without memory:// prefix)
|
||||
depth: How deep to traverse relations
|
||||
timeframe: Time filter (e.g., "7d", "1 week")
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page
|
||||
max_related: Maximum related items per result
|
||||
|
||||
Returns:
|
||||
GraphContext with hierarchical results
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params: dict = {
|
||||
"depth": depth,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
}
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe
|
||||
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/{path}",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
async def recent(
|
||||
self,
|
||||
*,
|
||||
timeframe: str = "7d",
|
||||
depth: int = 1,
|
||||
types: Optional[list[str]] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity.
|
||||
|
||||
Args:
|
||||
timeframe: Time filter (e.g., "7d", "1 week", "2 days ago")
|
||||
depth: How deep to traverse relations
|
||||
types: Filter by item types
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page
|
||||
|
||||
Returns:
|
||||
GraphContext with recent activity
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params: dict = {
|
||||
"timeframe": timeframe,
|
||||
"depth": depth,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
if types:
|
||||
# Join types as comma-separated string if provided
|
||||
params["type"] = ",".join(types) if isinstance(types, list) else types
|
||||
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/recent",
|
||||
params=params,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Typed client for project API operations.
|
||||
|
||||
Encapsulates project-level endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
|
||||
|
||||
class ProjectClient:
|
||||
"""Typed client for project management operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for project endpoints
|
||||
- Response validation via Pydantic models
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Note: This client does not require a project_id since it operates
|
||||
across projects.
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = ProjectClient(http_client)
|
||||
projects = await client.list_projects()
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient):
|
||||
"""Initialize the project client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
"""
|
||||
self.http_client = http_client
|
||||
|
||||
async def list_projects(self) -> ProjectList:
|
||||
"""List all available projects.
|
||||
|
||||
Returns:
|
||||
ProjectList with all projects and default project name
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
"/projects/projects",
|
||||
)
|
||||
return ProjectList.model_validate(response.json())
|
||||
|
||||
async def create_project(self, project_data: dict[str, Any]) -> ProjectStatusResponse:
|
||||
"""Create a new project.
|
||||
|
||||
Args:
|
||||
project_data: Project creation data (name, path, set_default)
|
||||
|
||||
Returns:
|
||||
ProjectStatusResponse with creation result
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
"/projects/projects",
|
||||
json=project_data,
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
async def delete_project(self, project_external_id: str) -> ProjectStatusResponse:
|
||||
"""Delete a project by its external ID.
|
||||
|
||||
Args:
|
||||
project_external_id: Project external ID (UUID)
|
||||
|
||||
Returns:
|
||||
ProjectStatusResponse with deletion result
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_delete(
|
||||
self.http_client,
|
||||
f"/v2/projects/{project_external_id}",
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Typed client for resource API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
|
||||
class ResourceClient:
|
||||
"""Typed client for resource operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/resource/*
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Note: This client returns raw Response objects for resources since they
|
||||
may be text, images, or other binary content that needs special handling.
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = ResourceClient(http_client, project_id)
|
||||
response = await client.read(entity_id)
|
||||
text = response.text
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the resource client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/resource"
|
||||
|
||||
async def read(
|
||||
self,
|
||||
entity_id: str,
|
||||
*,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
) -> Response:
|
||||
"""Read a resource by entity ID.
|
||||
|
||||
Args:
|
||||
entity_id: Entity external_id (UUID)
|
||||
page: Optional page number for paginated content
|
||||
page_size: Optional page size for paginated content
|
||||
|
||||
Returns:
|
||||
Raw HTTP Response (caller handles text/binary content)
|
||||
|
||||
Raises:
|
||||
ToolError: If the resource is not found or request fails
|
||||
"""
|
||||
params: dict = {}
|
||||
if page is not None:
|
||||
params["page"] = page
|
||||
if page_size is not None:
|
||||
params["page_size"] = page_size
|
||||
|
||||
return await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/{entity_id}",
|
||||
params=params if params else None,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Typed client for search API operations.
|
||||
|
||||
Encapsulates all /v2/projects/{project_id}/search/* endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Typed client for search operations.
|
||||
|
||||
Centralizes:
|
||||
- API path construction for /v2/projects/{project_id}/search/*
|
||||
- Response validation via Pydantic models
|
||||
- Consistent error handling through call_* utilities
|
||||
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = SearchClient(http_client, project_id)
|
||||
results = await client.search(search_query.model_dump())
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
"""Initialize the search client.
|
||||
|
||||
Args:
|
||||
http_client: HTTPX AsyncClient for making requests
|
||||
project_id: Project external_id (UUID) for API calls
|
||||
"""
|
||||
self.http_client = http_client
|
||||
self.project_id = project_id
|
||||
self._base_path = f"/v2/projects/{project_id}/search"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: dict[str, Any],
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> SearchResponse:
|
||||
"""Search across all content in the knowledge base.
|
||||
|
||||
Args:
|
||||
query: Search query dict (from SearchQuery.model_dump())
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page
|
||||
|
||||
Returns:
|
||||
SearchResponse with results and pagination
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/",
|
||||
json=query,
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""MCP composition root for Basic Memory.
|
||||
|
||||
This container owns reading ConfigManager and environment variables for the
|
||||
MCP server entrypoint. Downstream modules receive config/dependencies explicitly
|
||||
rather than reading globals.
|
||||
|
||||
Design principles:
|
||||
- Only this module reads ConfigManager directly
|
||||
- Runtime mode (cloud/local/test) is resolved here
|
||||
- File sync decisions are centralized here
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class McpContainer:
|
||||
"""Composition root for the MCP server entrypoint.
|
||||
|
||||
Holds resolved configuration and runtime context.
|
||||
Created once at server startup, then used to wire dependencies.
|
||||
"""
|
||||
|
||||
config: BasicMemoryConfig
|
||||
mode: RuntimeMode
|
||||
|
||||
@classmethod
|
||||
def create(cls) -> "McpContainer":
|
||||
"""Create container by reading ConfigManager.
|
||||
|
||||
This is the single point where MCP reads global config.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
mode = resolve_runtime_mode(
|
||||
cloud_mode_enabled=config.cloud_mode_enabled,
|
||||
is_test_env=config.is_test_env,
|
||||
)
|
||||
return cls(config=config, mode=mode)
|
||||
|
||||
# --- Runtime Mode Properties ---
|
||||
|
||||
@property
|
||||
def should_sync_files(self) -> bool:
|
||||
"""Whether local file sync should be started.
|
||||
|
||||
Sync is enabled when:
|
||||
- sync_changes is True in config
|
||||
- Not in test mode (tests manage their own sync)
|
||||
- Not in cloud mode (cloud handles sync differently)
|
||||
"""
|
||||
return (
|
||||
self.config.sync_changes and not self.mode.is_test and not self.mode.is_cloud
|
||||
)
|
||||
|
||||
@property
|
||||
def sync_skip_reason(self) -> str | None:
|
||||
"""Reason why sync is skipped, or None if sync should run.
|
||||
|
||||
Useful for logging why sync was disabled.
|
||||
"""
|
||||
if self.mode.is_test:
|
||||
return "Test environment detected"
|
||||
if self.mode.is_cloud:
|
||||
return "Cloud mode enabled"
|
||||
if not self.config.sync_changes:
|
||||
return "Sync changes disabled"
|
||||
return None
|
||||
|
||||
def create_sync_coordinator(self) -> "SyncCoordinator":
|
||||
"""Create a SyncCoordinator with this container's settings.
|
||||
|
||||
Returns:
|
||||
SyncCoordinator configured for this runtime environment
|
||||
"""
|
||||
# Deferred import to avoid circular dependency
|
||||
from basic_memory.sync import SyncCoordinator
|
||||
|
||||
return SyncCoordinator(
|
||||
config=self.config,
|
||||
should_sync=self.should_sync_files,
|
||||
skip_reason=self.sync_skip_reason,
|
||||
)
|
||||
|
||||
|
||||
# Module-level container instance (set by lifespan)
|
||||
_container: McpContainer | None = None
|
||||
|
||||
|
||||
def get_container() -> McpContainer:
|
||||
"""Get the current MCP container.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If container hasn't been initialized
|
||||
"""
|
||||
if _container is None:
|
||||
raise RuntimeError("MCP container not initialized. Call set_container() first.")
|
||||
return _container
|
||||
|
||||
|
||||
def set_container(container: McpContainer) -> None:
|
||||
"""Set the MCP container (called by lifespan)."""
|
||||
global _container
|
||||
_container = container
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
Provides project lookup utilities for MCP tools.
|
||||
Handles project validation and context management in one place.
|
||||
|
||||
Note: This module uses ProjectResolver for unified project resolution.
|
||||
The resolve_project_parameter function is a thin wrapper for backwards
|
||||
compatibility with existing MCP tools.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, List
|
||||
from httpx import AsyncClient
|
||||
from httpx._types import (
|
||||
@@ -14,16 +17,25 @@ from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.project_resolver import ProjectResolver
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
async def resolve_project_parameter(
|
||||
project: Optional[str] = None, allow_discovery: bool = False
|
||||
project: Optional[str] = None,
|
||||
allow_discovery: bool = False,
|
||||
cloud_mode: Optional[bool] = None,
|
||||
default_project_mode: Optional[bool] = None,
|
||||
default_project: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve project parameter using three-tier hierarchy.
|
||||
|
||||
if config.cloud_mode:
|
||||
This is a thin wrapper around ProjectResolver for backwards compatibility.
|
||||
New code should consider using ProjectResolver directly for more detailed
|
||||
resolution information.
|
||||
|
||||
if cloud_mode:
|
||||
project is required (unless allow_discovery=True for tools that support discovery mode)
|
||||
else:
|
||||
Resolution order:
|
||||
@@ -35,41 +47,31 @@ async def resolve_project_parameter(
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None in cloud mode for discovery mode
|
||||
(used by tools like recent_activity that can operate across all projects)
|
||||
cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager.
|
||||
default_project_mode: Optional explicit default project mode. If not provided, reads from ConfigManager.
|
||||
default_project: Optional explicit default project. If not provided, reads from ConfigManager.
|
||||
|
||||
Returns:
|
||||
Resolved project name or None if no resolution possible
|
||||
"""
|
||||
# Load config for any values not explicitly provided
|
||||
if cloud_mode is None or default_project_mode is None or default_project is None:
|
||||
config = ConfigManager().config
|
||||
if cloud_mode is None:
|
||||
cloud_mode = config.cloud_mode
|
||||
if default_project_mode is None:
|
||||
default_project_mode = config.default_project_mode
|
||||
if default_project is None:
|
||||
default_project = config.default_project
|
||||
|
||||
config = ConfigManager().config
|
||||
# if cloud_mode, project is required (unless discovery mode is allowed)
|
||||
if config.cloud_mode:
|
||||
if project:
|
||||
logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
|
||||
return project
|
||||
elif allow_discovery:
|
||||
logger.debug("cloud_mode: discovery mode allowed, returning None")
|
||||
return None
|
||||
else:
|
||||
raise ValueError("No project specified. Project is required for cloud mode.")
|
||||
|
||||
# Priority 1: CLI constraint overrides everything (--project arg sets env var)
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
if constrained_project:
|
||||
logger.debug(f"Using CLI constrained project: {constrained_project}")
|
||||
return constrained_project
|
||||
|
||||
# Priority 2: Explicit project parameter
|
||||
if project:
|
||||
logger.debug(f"Using explicit project parameter: {project}")
|
||||
return project
|
||||
|
||||
# Priority 3: Default project mode
|
||||
if config.default_project_mode:
|
||||
logger.debug(f"Using default project from config: {config.default_project}")
|
||||
return config.default_project
|
||||
|
||||
# No resolution possible
|
||||
return None
|
||||
# Create resolver with configuration and resolve
|
||||
resolver = ProjectResolver.from_env(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
)
|
||||
result = resolver.resolve(project=project, allow_discovery=allow_discovery)
|
||||
return result.project
|
||||
|
||||
|
||||
async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
Basic Memory FastMCP server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app, initialize_file_sync
|
||||
from basic_memory.mcp.container import McpContainer, set_container
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.telemetry import show_notice_if_needed, track_app_started
|
||||
|
||||
|
||||
@@ -21,11 +20,15 @@ async def lifespan(app: FastMCP):
|
||||
Handles:
|
||||
- Database initialization and migrations
|
||||
- Telemetry notice and tracking
|
||||
- File sync in background (if enabled and not in cloud mode)
|
||||
- File sync via SyncCoordinator (if enabled and not in cloud mode)
|
||||
- Proper cleanup on shutdown
|
||||
"""
|
||||
app_config = ConfigManager().config
|
||||
logger.info("Starting Basic Memory MCP server")
|
||||
# --- Composition Root ---
|
||||
# Create container and read config (single point of config access)
|
||||
container = McpContainer.create()
|
||||
set_container(container)
|
||||
|
||||
logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
|
||||
|
||||
# Show telemetry notice (first run only) and track startup
|
||||
show_notice_if_needed()
|
||||
@@ -37,35 +40,18 @@ async def lifespan(app: FastMCP):
|
||||
engine_was_none = db._engine is None
|
||||
|
||||
# Initialize app (runs migrations, reconciles projects)
|
||||
await initialize_app(app_config)
|
||||
await initialize_app(container.config)
|
||||
|
||||
# Start file sync as background task (if enabled and not in cloud mode)
|
||||
sync_task = None
|
||||
if app_config.is_test_env:
|
||||
logger.info("Test environment detected - skipping local file sync")
|
||||
elif app_config.sync_changes and not app_config.cloud_mode_enabled: # pragma: no cover
|
||||
logger.info("Starting file sync in background")
|
||||
|
||||
async def _file_sync_runner() -> None:
|
||||
await initialize_file_sync(app_config)
|
||||
|
||||
sync_task = asyncio.create_task(_file_sync_runner())
|
||||
elif app_config.cloud_mode_enabled: # pragma: no cover
|
||||
logger.info("Cloud mode enabled - skipping local file sync")
|
||||
else: # pragma: no cover
|
||||
logger.info("Sync changes disabled - skipping file sync")
|
||||
# Create and start sync coordinator (lifecycle centralized in coordinator)
|
||||
sync_coordinator = container.create_sync_coordinator()
|
||||
await sync_coordinator.start()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown
|
||||
# Shutdown - coordinator handles clean task cancellation
|
||||
logger.info("Shutting down Basic Memory MCP server")
|
||||
if sync_task: # pragma: no cover
|
||||
sync_task.cancel()
|
||||
try:
|
||||
await sync_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("File sync task cancelled")
|
||||
await sync_coordinator.stop()
|
||||
|
||||
# Only shutdown DB if we created it (not if test fixture provided it)
|
||||
if engine_was_none:
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
@@ -106,15 +105,16 @@ async def build_context(
|
||||
# Get the active project using the new stateless approach
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/memory/{memory_url_path(url)}",
|
||||
params={
|
||||
"depth": depth,
|
||||
"timeframe": timeframe,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"max_related": max_related,
|
||||
},
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import MemoryClient
|
||||
|
||||
# Use typed MemoryClient for API calls
|
||||
memory_client = MemoryClient(client, active_project.external_id)
|
||||
return await memory_client.build_context(
|
||||
memory_url_path(url),
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -6,11 +6,9 @@ from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.tools.utils import call_delete, resolve_entity_id
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
|
||||
|
||||
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
||||
@@ -208,9 +206,15 @@ async def delete_note(
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
except ToolError as e:
|
||||
# If entity not found, return False (note doesn't exist)
|
||||
if "Entity not found" in str(e) or "not found" in str(e).lower():
|
||||
@@ -226,10 +230,7 @@ async def delete_note(
|
||||
|
||||
try:
|
||||
# Call the DELETE endpoint
|
||||
response = await call_delete(
|
||||
client, f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
)
|
||||
result = DeleteEntitiesResponse.model_validate(response.json())
|
||||
result = await knowledge_client.delete_entity(entity_id)
|
||||
|
||||
if result.deleted:
|
||||
logger.info(
|
||||
|
||||
@@ -8,9 +8,7 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_patch, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
def _format_error_response(
|
||||
@@ -236,8 +234,14 @@ async def edit_note(
|
||||
|
||||
# Use the PATCH endpoint to edit the entity
|
||||
try:
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
|
||||
# Prepare the edit request data
|
||||
edit_data = {
|
||||
@@ -254,9 +258,7 @@ async def edit_note(
|
||||
edit_data["expected_replacements"] = str(expected_replacements)
|
||||
|
||||
# Call the PATCH endpoint
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_patch(client, url, json=edit_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
result = await knowledge_client.patch_entity(entity_id, edit_data)
|
||||
|
||||
# Format summary
|
||||
summary = [
|
||||
@@ -311,11 +313,10 @@ async def edit_note(
|
||||
permalink=result.permalink,
|
||||
observations_count=len(result.observations),
|
||||
relations_count=len(result.relations),
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
result = "\n".join(summary)
|
||||
return add_project_metadata(result, active_project.name)
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing note: {e}")
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
|
||||
|
||||
@@ -68,26 +67,16 @@ async def list_directory(
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Prepare query parameters
|
||||
params = {
|
||||
"dir_name": dir_name,
|
||||
"depth": str(depth),
|
||||
}
|
||||
if file_name_glob:
|
||||
params["file_name_glob"] = file_name_glob
|
||||
|
||||
logger.debug(
|
||||
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
||||
)
|
||||
|
||||
# Call the API endpoint
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/directory/list",
|
||||
params=params,
|
||||
)
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import DirectoryClient
|
||||
|
||||
nodes = response.json()
|
||||
# Use typed DirectoryClient for API calls
|
||||
directory_client = DirectoryClient(client, active_project.external_id)
|
||||
nodes = await directory_client.list(dir_name, depth=depth, file_name_glob=file_name_glob)
|
||||
|
||||
if not nodes:
|
||||
filter_desc = ""
|
||||
|
||||
@@ -8,10 +8,7 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_put, resolve_entity_id
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.schemas.project_info import ProjectList
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
@@ -31,9 +28,12 @@ async def _detect_cross_project_move_attempt(
|
||||
Error message with guidance if cross-project move is detected, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Get list of all available projects to check against
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
project_list = await project_client.list_projects()
|
||||
project_names = [p.name.lower() for p in project_list.projects]
|
||||
|
||||
# Check if destination path contains any project names
|
||||
@@ -436,15 +436,19 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
||||
return cross_project_error
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Get the source entity information for extension validation
|
||||
source_ext = "md" # Default to .md if we can't determine source extension
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
# Fetch source entity information to get the current file extension
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
source_entity = await knowledge_client.get_entity(entity_id)
|
||||
if "." in source_entity.file_path:
|
||||
source_ext = source_entity.file_path.split(".")[-1]
|
||||
except Exception as e:
|
||||
@@ -475,11 +479,9 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
# Get the source entity to check its file extension
|
||||
try:
|
||||
# Resolve identifier to entity ID (might already be cached from above)
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
# Fetch source entity information
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
source_entity = await knowledge_client.get_entity(entity_id)
|
||||
|
||||
# Extract file extensions
|
||||
source_ext = (
|
||||
@@ -515,17 +517,10 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
|
||||
try:
|
||||
# Resolve identifier to entity ID for the move operation
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
|
||||
# Prepare move request (v2 API only needs destination_path)
|
||||
move_data = {
|
||||
"destination_path": destination_path,
|
||||
}
|
||||
|
||||
# Call the v2 move API endpoint (PUT method, entity_id in URL)
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}/move"
|
||||
response = await call_put(client, url, json=move_data)
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
# Call the move API using KnowledgeClient
|
||||
result = await knowledge_client.move_entity(entity_id, destination_path)
|
||||
|
||||
# Build success message
|
||||
result_lines = [
|
||||
@@ -544,7 +539,6 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
identifier=identifier,
|
||||
destination_path=destination_path,
|
||||
project=active_project.name,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
@@ -9,12 +9,7 @@ from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectList,
|
||||
ProjectStatusResponse,
|
||||
ProjectInfoRequest,
|
||||
)
|
||||
from basic_memory.schemas.project_info import ProjectInfoRequest
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
@@ -49,9 +44,12 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
||||
# Check if server is constrained to a specific project
|
||||
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
|
||||
# Get projects from API
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
project_list = await project_client.list_projects()
|
||||
|
||||
if constrained_project:
|
||||
result = f"Project: {constrained_project}\n\n"
|
||||
@@ -109,9 +107,12 @@ async def create_memory_project(
|
||||
name=project_name, path=project_path, set_default=set_default
|
||||
)
|
||||
|
||||
# Call API to create project
|
||||
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
status_response = await project_client.create_project(project_request.model_dump())
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
@@ -160,9 +161,14 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
if context: # pragma: no cover
|
||||
await context.info(f"Deleting project: {project_name}")
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import ProjectClient
|
||||
|
||||
# Use typed ProjectClient for API calls
|
||||
project_client = ProjectClient(client)
|
||||
|
||||
# Get project info before deletion to validate it exists
|
||||
response = await call_get(client, "/projects/projects")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
project_list = await project_client.list_projects()
|
||||
|
||||
# Find the project by permalink (derived from name).
|
||||
# Note: The API response uses `ProjectItem` which derives `permalink` from `name`,
|
||||
@@ -181,9 +187,8 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
||||
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
||||
)
|
||||
|
||||
# Call v2 API to delete project using project external_id
|
||||
response = await call_delete(client, f"/v2/projects/{target_project.external_id}")
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
# Delete project using project external_id
|
||||
status_response = await project_client.delete_project(target_project.external_id)
|
||||
|
||||
result = f"✓ {status_response.message}\n\n"
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
@@ -105,16 +104,19 @@ async def read_note(
|
||||
f"Attempting to read note from Project: {active_project.name} identifier: {entity_path}"
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
|
||||
# Use typed clients for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
try:
|
||||
# Try to resolve identifier to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, entity_path)
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path)
|
||||
|
||||
# Fetch content using entity ID
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
@@ -136,14 +138,10 @@ async def read_note(
|
||||
if result.permalink:
|
||||
try:
|
||||
# Resolve the permalink to entity ID
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, result.permalink)
|
||||
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
||||
|
||||
# Fetch content using the entity ID
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
||||
params={"page": page, "page_size": page_size},
|
||||
)
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Found note by title search: {result.permalink}")
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastmcp import Context
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
|
||||
|
||||
@@ -365,13 +364,16 @@ async def search_notes(
|
||||
logger.info(f"Searching for {search_query} in project {active_project.name}")
|
||||
|
||||
try:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/search/",
|
||||
json=search_query.model_dump(),
|
||||
params={"page": page, "page_size": page_size},
|
||||
# Import here to avoid circular import (tools → clients → utils → tools)
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
# Use typed SearchClient for API calls
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
result = await search_client.search(
|
||||
search_query.model_dump(),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
result = SearchResponse.model_validate(response.json())
|
||||
|
||||
# Check if we got no results and provide helpful guidance
|
||||
if not result.results:
|
||||
|
||||
@@ -7,9 +7,7 @@ from loguru import logger
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
from basic_memory.telemetry import track_mcp_tool
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import parse_tags, validate_project_path
|
||||
@@ -153,13 +151,17 @@ async def write_note(
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Try to create the entity first (optimistic create)
|
||||
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
||||
action = "Created" # Default to created
|
||||
try:
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities"
|
||||
response = await call_post(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
result = await knowledge_client.create_entity(entity.model_dump())
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
@@ -172,10 +174,8 @@ async def write_note(
|
||||
try:
|
||||
if not entity.permalink:
|
||||
raise ValueError("Entity permalink is required for updates") # pragma: no cover
|
||||
entity_id = await resolve_entity_id(client, active_project.external_id, entity.permalink)
|
||||
url = f"/v2/projects/{active_project.external_id}/knowledge/entities/{entity_id}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
@@ -224,7 +224,7 @@ async def write_note(
|
||||
|
||||
# Log the response with structured data
|
||||
logger.info(
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
)
|
||||
result = "\n".join(summary)
|
||||
return add_project_metadata(result, active_project.name)
|
||||
summary_result = "\n".join(summary)
|
||||
return add_project_metadata(summary_result, active_project.name)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Unified project resolution across MCP, API, and CLI.
|
||||
|
||||
This module provides a single canonical implementation of project resolution
|
||||
logic, eliminating duplicated decision trees across the codebase.
|
||||
|
||||
The resolution follows a three-tier hierarchy:
|
||||
1. Constrained mode: BASIC_MEMORY_MCP_PROJECT env var (highest priority)
|
||||
2. Explicit parameter: Project passed directly to operation
|
||||
3. Default project: Used when default_project_mode=true (lowest priority)
|
||||
|
||||
In cloud mode, project is required unless discovery mode is explicitly allowed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class ResolutionMode(Enum):
|
||||
"""How the project was resolved."""
|
||||
|
||||
CLOUD_EXPLICIT = auto() # Explicit project in cloud mode
|
||||
CLOUD_DISCOVERY = auto() # Discovery mode allowed in cloud (no project)
|
||||
ENV_CONSTRAINT = auto() # BASIC_MEMORY_MCP_PROJECT env var
|
||||
EXPLICIT = auto() # Explicit project parameter
|
||||
DEFAULT = auto() # default_project with default_project_mode=true
|
||||
NONE = auto() # No resolution possible
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedProject:
|
||||
"""Result of project resolution.
|
||||
|
||||
Attributes:
|
||||
project: The resolved project name, or None if not resolved
|
||||
mode: How the project was resolved
|
||||
reason: Human-readable explanation of resolution
|
||||
"""
|
||||
|
||||
project: Optional[str]
|
||||
mode: ResolutionMode
|
||||
reason: str
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
"""Whether a project was successfully resolved."""
|
||||
return self.project is not None
|
||||
|
||||
@property
|
||||
def is_discovery_mode(self) -> bool:
|
||||
"""Whether we're in discovery mode (no specific project)."""
|
||||
return self.mode == ResolutionMode.CLOUD_DISCOVERY or (
|
||||
self.mode == ResolutionMode.NONE and self.project is None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectResolver:
|
||||
"""Unified project resolution logic.
|
||||
|
||||
Resolves the effective project given requested project, environment
|
||||
constraints, and configuration settings.
|
||||
|
||||
This is the single canonical implementation of project resolution,
|
||||
used by MCP tools, API routes, and CLI commands.
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode (project required)
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
constrained_project: Optional env-constrained project override
|
||||
(typically from BASIC_MEMORY_MCP_PROJECT)
|
||||
"""
|
||||
|
||||
cloud_mode: bool = False
|
||||
default_project_mode: bool = False
|
||||
default_project: Optional[str] = None
|
||||
constrained_project: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
cloud_mode: bool = False,
|
||||
default_project_mode: bool = False,
|
||||
default_project: Optional[str] = None,
|
||||
) -> "ProjectResolver":
|
||||
"""Create resolver with constrained_project from environment.
|
||||
|
||||
Args:
|
||||
cloud_mode: Whether running in cloud mode
|
||||
default_project_mode: Whether to use default project when not specified
|
||||
default_project: The default project name
|
||||
|
||||
Returns:
|
||||
ProjectResolver configured with current environment
|
||||
"""
|
||||
constrained = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
||||
return cls(
|
||||
cloud_mode=cloud_mode,
|
||||
default_project_mode=default_project_mode,
|
||||
default_project=default_project,
|
||||
constrained_project=constrained,
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
allow_discovery: bool = False,
|
||||
) -> ResolvedProject:
|
||||
"""Resolve project using the three-tier hierarchy.
|
||||
|
||||
Resolution order:
|
||||
1. Cloud mode check (project required unless discovery allowed)
|
||||
2. Constrained project from env var (highest priority in local mode)
|
||||
3. Explicit project parameter
|
||||
4. Default project if default_project_mode=true
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
allow_discovery: If True, allows returning None in cloud mode
|
||||
for discovery operations (e.g., recent_activity across projects)
|
||||
|
||||
Returns:
|
||||
ResolvedProject with project name, resolution mode, and reason
|
||||
|
||||
Raises:
|
||||
ValueError: If in cloud mode and no project specified (unless discovery allowed)
|
||||
"""
|
||||
# --- Cloud Mode Handling ---
|
||||
# In cloud mode, project is required unless discovery is explicitly allowed
|
||||
if self.cloud_mode:
|
||||
if project:
|
||||
logger.debug(f"Cloud mode: using explicit project '{project}'")
|
||||
return ResolvedProject(
|
||||
project=project,
|
||||
mode=ResolutionMode.CLOUD_EXPLICIT,
|
||||
reason=f"Explicit project in cloud mode: {project}",
|
||||
)
|
||||
elif allow_discovery:
|
||||
logger.debug("Cloud mode: discovery mode allowed, no project required")
|
||||
return ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.CLOUD_DISCOVERY,
|
||||
reason="Discovery mode enabled in cloud",
|
||||
)
|
||||
else:
|
||||
raise ValueError("No project specified. Project is required for cloud mode.")
|
||||
|
||||
# --- Local Mode: Three-Tier Hierarchy ---
|
||||
|
||||
# Priority 1: CLI constraint overrides everything
|
||||
if self.constrained_project:
|
||||
logger.debug(f"Using CLI constrained project: {self.constrained_project}")
|
||||
return ResolvedProject(
|
||||
project=self.constrained_project,
|
||||
mode=ResolutionMode.ENV_CONSTRAINT,
|
||||
reason=f"Environment constraint: BASIC_MEMORY_MCP_PROJECT={self.constrained_project}",
|
||||
)
|
||||
|
||||
# Priority 2: Explicit project parameter
|
||||
if project:
|
||||
logger.debug(f"Using explicit project parameter: {project}")
|
||||
return ResolvedProject(
|
||||
project=project,
|
||||
mode=ResolutionMode.EXPLICIT,
|
||||
reason=f"Explicit parameter: {project}",
|
||||
)
|
||||
|
||||
# Priority 3: Default project mode
|
||||
if self.default_project_mode and self.default_project:
|
||||
logger.debug(f"Using default project from config: {self.default_project}")
|
||||
return ResolvedProject(
|
||||
project=self.default_project,
|
||||
mode=ResolutionMode.DEFAULT,
|
||||
reason=f"Default project mode: {self.default_project}",
|
||||
)
|
||||
|
||||
# No resolution possible
|
||||
logger.debug("No project resolution possible")
|
||||
return ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.NONE,
|
||||
reason="No project specified and default_project_mode is disabled",
|
||||
)
|
||||
|
||||
def require_project(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> ResolvedProject:
|
||||
"""Resolve project, raising an error if not resolved.
|
||||
|
||||
Convenience method for operations that require a project.
|
||||
|
||||
Args:
|
||||
project: Optional explicit project parameter
|
||||
error_message: Custom error message if project not resolved
|
||||
|
||||
Returns:
|
||||
ResolvedProject (always with a non-None project)
|
||||
|
||||
Raises:
|
||||
ValueError: If project could not be resolved
|
||||
"""
|
||||
result = self.resolve(project, allow_discovery=False)
|
||||
if not result.is_resolved:
|
||||
msg = error_message or (
|
||||
"No project specified. Either set 'default_project_mode=true' in config, "
|
||||
"or provide a 'project' argument."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProjectResolver",
|
||||
"ResolvedProject",
|
||||
"ResolutionMode",
|
||||
]
|
||||
@@ -33,7 +33,7 @@ class EntityRepository(Repository[Entity]):
|
||||
"""
|
||||
super().__init__(session_maker, Entity, project_id=project_id)
|
||||
|
||||
async def get_by_id(self, entity_id: int) -> Optional[Entity]:
|
||||
async def get_by_id(self, entity_id: int) -> Optional[Entity]: # pragma: no cover
|
||||
"""Get entity by numeric ID.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -68,20 +68,27 @@ class SearchRepository(Protocol):
|
||||
|
||||
|
||||
def create_search_repository(
|
||||
session_maker: async_sessionmaker[AsyncSession], project_id: int
|
||||
session_maker: async_sessionmaker[AsyncSession],
|
||||
project_id: int,
|
||||
database_backend: Optional[DatabaseBackend] = None,
|
||||
) -> SearchRepository:
|
||||
"""Factory function to create the appropriate search repository based on database backend.
|
||||
|
||||
Args:
|
||||
session_maker: SQLAlchemy async session maker
|
||||
project_id: Project ID for the repository
|
||||
database_backend: Optional explicit backend. If not provided, reads from ConfigManager.
|
||||
Prefer passing explicitly from composition roots.
|
||||
|
||||
Returns:
|
||||
SearchRepository: Backend-appropriate search repository instance
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
# Prefer explicit parameter; fall back to ConfigManager for backwards compatibility
|
||||
if database_backend is None:
|
||||
config = ConfigManager().config
|
||||
database_backend = config.database_backend
|
||||
|
||||
if config.database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
|
||||
if database_backend == DatabaseBackend.POSTGRES: # pragma: no cover
|
||||
return PostgresSearchRepository(session_maker, project_id=project_id) # pragma: no cover
|
||||
else:
|
||||
return SQLiteSearchRepository(session_maker, project_id=project_id)
|
||||
|
||||
@@ -27,17 +27,15 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create FTS5 virtual table for search.
|
||||
"""Create FTS5 virtual table for search if it doesn't exist.
|
||||
|
||||
Note: Drops any existing search_index table first to ensure FTS5 virtual table creation.
|
||||
This is necessary because Base.metadata.create_all() might create a regular table.
|
||||
Uses CREATE VIRTUAL TABLE IF NOT EXISTS to preserve existing indexed data
|
||||
across server restarts.
|
||||
"""
|
||||
logger.info("Initializing SQLite FTS5 search index")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Drop any existing regular or virtual table first
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_index"))
|
||||
# Create FTS5 virtual table
|
||||
# Create FTS5 virtual table if it doesn't exist
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Runtime mode resolution for Basic Memory.
|
||||
|
||||
This module centralizes runtime mode detection, ensuring cloud/local/test
|
||||
determination happens in one place rather than scattered across modules.
|
||||
|
||||
Composition roots (containers) read ConfigManager and use this module
|
||||
to resolve the runtime mode, then pass the result downstream.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class RuntimeMode(Enum):
|
||||
"""Runtime modes for Basic Memory."""
|
||||
|
||||
LOCAL = auto() # Local standalone mode (default)
|
||||
CLOUD = auto() # Cloud mode with remote sync
|
||||
TEST = auto() # Test environment
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
cloud_mode_enabled: bool,
|
||||
is_test_env: bool,
|
||||
) -> RuntimeMode:
|
||||
"""Resolve the runtime mode from configuration flags.
|
||||
|
||||
This is the single source of truth for mode resolution.
|
||||
Composition roots call this with config values they've read.
|
||||
|
||||
Args:
|
||||
cloud_mode_enabled: Whether cloud mode is enabled in config
|
||||
is_test_env: Whether running in test environment
|
||||
|
||||
Returns:
|
||||
The resolved RuntimeMode
|
||||
"""
|
||||
# Trigger: test environment is detected
|
||||
# Why: tests need special handling (no file sync, isolated DB)
|
||||
# Outcome: returns TEST mode, skipping cloud mode check
|
||||
if is_test_env:
|
||||
return RuntimeMode.TEST
|
||||
|
||||
# Trigger: cloud mode is enabled in config
|
||||
# Why: cloud mode changes auth, sync, and API behavior
|
||||
# Outcome: returns CLOUD mode for remote-first behavior
|
||||
if cloud_mode_enabled:
|
||||
return RuntimeMode.CLOUD
|
||||
|
||||
return RuntimeMode.LOCAL
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Basic Memory sync services."""
|
||||
|
||||
from .coordinator import SyncCoordinator, SyncStatus
|
||||
from .sync_service import SyncService
|
||||
from .watch_service import WatchService
|
||||
|
||||
__all__ = ["SyncService", "WatchService"]
|
||||
__all__ = ["SyncService", "WatchService", "SyncCoordinator", "SyncStatus"]
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""SyncCoordinator - centralized sync/watch lifecycle management.
|
||||
|
||||
This module provides a single coordinator that manages the lifecycle of
|
||||
file synchronization and watch services across all entry points (API, MCP, CLI).
|
||||
|
||||
The coordinator handles:
|
||||
- Starting/stopping watch service
|
||||
- Scheduling background sync
|
||||
- Reporting status
|
||||
- Clean shutdown behavior
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
|
||||
class SyncStatus(Enum):
|
||||
"""Status of the sync coordinator."""
|
||||
|
||||
NOT_STARTED = auto()
|
||||
STARTING = auto()
|
||||
RUNNING = auto()
|
||||
STOPPING = auto()
|
||||
STOPPED = auto()
|
||||
ERROR = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncCoordinator:
|
||||
"""Centralized coordinator for sync/watch lifecycle.
|
||||
|
||||
Manages the lifecycle of file synchronization services, providing:
|
||||
- Unified start/stop interface
|
||||
- Status tracking
|
||||
- Clean shutdown with proper task cancellation
|
||||
|
||||
Args:
|
||||
config: BasicMemoryConfig with sync settings
|
||||
should_sync: Whether sync should be enabled (from container decision)
|
||||
skip_reason: Human-readable reason if sync is skipped
|
||||
|
||||
Usage:
|
||||
coordinator = SyncCoordinator(config=config, should_sync=True)
|
||||
await coordinator.start()
|
||||
# ... application runs ...
|
||||
await coordinator.stop()
|
||||
"""
|
||||
|
||||
config: BasicMemoryConfig
|
||||
should_sync: bool = True
|
||||
skip_reason: Optional[str] = None
|
||||
|
||||
# Internal state (not constructor args)
|
||||
_status: SyncStatus = field(default=SyncStatus.NOT_STARTED, init=False)
|
||||
_sync_task: Optional[asyncio.Task] = field(default=None, init=False)
|
||||
|
||||
@property
|
||||
def status(self) -> SyncStatus:
|
||||
"""Current status of the coordinator."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether sync is currently running."""
|
||||
return self._status == SyncStatus.RUNNING
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the sync/watch service if enabled.
|
||||
|
||||
This is a non-blocking call that starts the sync task in the background.
|
||||
Use stop() to cleanly shut down.
|
||||
"""
|
||||
if not self.should_sync:
|
||||
if self.skip_reason:
|
||||
logger.info(f"{self.skip_reason} - skipping local file sync")
|
||||
self._status = SyncStatus.STOPPED
|
||||
return
|
||||
|
||||
if self._status in (SyncStatus.RUNNING, SyncStatus.STARTING):
|
||||
logger.warning("Sync coordinator already running or starting")
|
||||
return
|
||||
|
||||
self._status = SyncStatus.STARTING
|
||||
logger.info("Starting file sync in background")
|
||||
|
||||
try:
|
||||
# Deferred import to avoid circular dependency
|
||||
from basic_memory.services.initialization import initialize_file_sync
|
||||
|
||||
async def _file_sync_runner() -> None: # pragma: no cover
|
||||
"""Run the file sync service."""
|
||||
try:
|
||||
await initialize_file_sync(self.config)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("File sync cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in file sync: {e}")
|
||||
self._status = SyncStatus.ERROR
|
||||
raise
|
||||
|
||||
self._sync_task = asyncio.create_task(_file_sync_runner())
|
||||
self._status = SyncStatus.RUNNING
|
||||
logger.info("Sync coordinator started successfully")
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Failed to start sync coordinator: {e}")
|
||||
self._status = SyncStatus.ERROR
|
||||
raise
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the sync/watch service cleanly.
|
||||
|
||||
Cancels the background task and waits for it to complete.
|
||||
Safe to call even if not running.
|
||||
"""
|
||||
if self._status in (SyncStatus.NOT_STARTED, SyncStatus.STOPPED):
|
||||
return
|
||||
|
||||
if self._sync_task is None: # pragma: no cover
|
||||
self._status = SyncStatus.STOPPED
|
||||
return
|
||||
|
||||
self._status = SyncStatus.STOPPING
|
||||
logger.info("Stopping sync coordinator...")
|
||||
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("File sync task cancelled successfully")
|
||||
|
||||
self._sync_task = None
|
||||
self._status = SyncStatus.STOPPED
|
||||
logger.info("Sync coordinator stopped")
|
||||
|
||||
def get_status_info(self) -> dict:
|
||||
"""Get status information for reporting.
|
||||
|
||||
Returns:
|
||||
Dictionary with status details for diagnostics
|
||||
"""
|
||||
return {
|
||||
"status": self._status.name,
|
||||
"should_sync": self.should_sync,
|
||||
"skip_reason": self.skip_reason,
|
||||
"has_task": self._sync_task is not None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SyncCoordinator",
|
||||
"SyncStatus",
|
||||
]
|
||||
@@ -22,7 +22,7 @@ def test_lifespan_shutdown_awaits_sync_task_cancellation(app, monkeypatch):
|
||||
- In the buggy version, shutdown proceeded directly to db.shutdown_db()
|
||||
immediately after calling cancel(), so at *entry* to shutdown_db the task
|
||||
is still not done.
|
||||
- In the fixed version, lifespan does `await sync_task` before shutdown_db,
|
||||
- In the fixed version, SyncCoordinator.stop() awaits the task before returning,
|
||||
so by the time shutdown_db is called, the task is done (cancelled).
|
||||
"""
|
||||
|
||||
@@ -34,29 +34,40 @@ def test_lifespan_shutdown_awaits_sync_task_cancellation(app, monkeypatch):
|
||||
import importlib
|
||||
|
||||
api_app_module = importlib.import_module("basic_memory.api.app")
|
||||
container_module = importlib.import_module("basic_memory.api.container")
|
||||
init_module = importlib.import_module("basic_memory.services.initialization")
|
||||
|
||||
# Keep startup cheap: we don't need real DB init for this ordering test.
|
||||
async def _noop_initialize_app(_app_config):
|
||||
return None
|
||||
|
||||
async def _fake_get_or_create_db(*_args, **_kwargs):
|
||||
return object(), object()
|
||||
|
||||
monkeypatch.setattr(api_app_module, "initialize_app", _noop_initialize_app)
|
||||
monkeypatch.setattr(api_app_module.db, "get_or_create_db", _fake_get_or_create_db)
|
||||
|
||||
# Patch the container's init_database to return fake objects
|
||||
async def _fake_init_database(self):
|
||||
self.engine = object()
|
||||
self.session_maker = object()
|
||||
return self.engine, self.session_maker
|
||||
|
||||
monkeypatch.setattr(container_module.ApiContainer, "init_database", _fake_init_database)
|
||||
|
||||
# Make the sync task long-lived so it must be cancelled on shutdown.
|
||||
# Patch at the source module where SyncCoordinator imports it.
|
||||
async def _fake_initialize_file_sync(_app_config):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(api_app_module, "initialize_file_sync", _fake_initialize_file_sync)
|
||||
monkeypatch.setattr(init_module, "initialize_file_sync", _fake_initialize_file_sync)
|
||||
|
||||
# Assert ordering: shutdown_db must be called only after the sync_task is done.
|
||||
async def _assert_sync_task_done_before_db_shutdown():
|
||||
assert api_app_module.app.state.sync_task is not None
|
||||
assert api_app_module.app.state.sync_task.done()
|
||||
# SyncCoordinator stores the task in _sync_task attribute.
|
||||
async def _assert_sync_task_done_before_db_shutdown(self):
|
||||
sync_coordinator = api_app_module.app.state.sync_coordinator
|
||||
assert sync_coordinator._sync_task is not None
|
||||
assert sync_coordinator._sync_task.done()
|
||||
|
||||
monkeypatch.setattr(api_app_module.db, "shutdown_db", _assert_sync_task_done_before_db_shutdown)
|
||||
monkeypatch.setattr(
|
||||
container_module.ApiContainer, "shutdown_database", _assert_sync_task_done_before_db_shutdown
|
||||
)
|
||||
|
||||
async def _run_client_once():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for API container composition root."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.api.container import (
|
||||
ApiContainer,
|
||||
get_container,
|
||||
set_container,
|
||||
)
|
||||
from basic_memory.runtime import RuntimeMode
|
||||
|
||||
|
||||
class TestApiContainer:
|
||||
"""Tests for ApiContainer."""
|
||||
|
||||
def test_create_from_config(self, app_config):
|
||||
"""Container can be created from config manager."""
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.config == app_config
|
||||
assert container.mode == RuntimeMode.LOCAL
|
||||
|
||||
def test_should_sync_files_when_enabled_and_not_test(self, app_config):
|
||||
"""Sync should be enabled when config says so and not in test mode."""
|
||||
app_config.sync_changes = True
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is True
|
||||
|
||||
def test_should_not_sync_files_when_disabled(self, app_config):
|
||||
"""Sync should be disabled when config says so."""
|
||||
app_config.sync_changes = False
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
def test_should_not_sync_files_in_test_mode(self, app_config):
|
||||
"""Sync should be disabled in test mode regardless of config."""
|
||||
app_config.sync_changes = True
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.TEST)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
|
||||
class TestContainerAccessors:
|
||||
"""Tests for container get/set functions."""
|
||||
|
||||
def test_get_container_raises_when_not_set(self, monkeypatch):
|
||||
"""get_container raises RuntimeError when container not initialized."""
|
||||
# Clear any existing container
|
||||
import basic_memory.api.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="API container not initialized"):
|
||||
get_container()
|
||||
|
||||
def test_set_and_get_container(self, app_config, monkeypatch):
|
||||
"""set_container allows get_container to return the container."""
|
||||
import basic_memory.api.container as container_module
|
||||
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
set_container(container)
|
||||
assert get_container() is container
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for CLI container composition root."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.container import (
|
||||
CliContainer,
|
||||
get_container,
|
||||
set_container,
|
||||
get_or_create_container,
|
||||
)
|
||||
from basic_memory.runtime import RuntimeMode
|
||||
|
||||
|
||||
class TestCliContainer:
|
||||
"""Tests for CliContainer."""
|
||||
|
||||
def test_create_from_config(self, app_config):
|
||||
"""Container can be created from config."""
|
||||
container = CliContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.config == app_config
|
||||
assert container.mode == RuntimeMode.LOCAL
|
||||
|
||||
def test_is_cloud_mode_when_cloud(self, app_config):
|
||||
"""is_cloud_mode returns True in cloud mode."""
|
||||
container = CliContainer(config=app_config, mode=RuntimeMode.CLOUD)
|
||||
assert container.is_cloud_mode is True
|
||||
|
||||
def test_is_cloud_mode_when_local(self, app_config):
|
||||
"""is_cloud_mode returns False in local mode."""
|
||||
container = CliContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.is_cloud_mode is False
|
||||
|
||||
def test_is_cloud_mode_when_test(self, app_config):
|
||||
"""is_cloud_mode returns False in test mode."""
|
||||
container = CliContainer(config=app_config, mode=RuntimeMode.TEST)
|
||||
assert container.is_cloud_mode is False
|
||||
|
||||
|
||||
class TestContainerAccessors:
|
||||
"""Tests for container get/set functions."""
|
||||
|
||||
def test_get_container_raises_when_not_set(self, monkeypatch):
|
||||
"""get_container raises RuntimeError when container not initialized."""
|
||||
import basic_memory.cli.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="CLI container not initialized"):
|
||||
get_container()
|
||||
|
||||
def test_set_and_get_container(self, app_config, monkeypatch):
|
||||
"""set_container allows get_container to return the container."""
|
||||
import basic_memory.cli.container as container_module
|
||||
|
||||
container = CliContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
set_container(container)
|
||||
assert get_container() is container
|
||||
|
||||
|
||||
class TestGetOrCreateContainer:
|
||||
"""Tests for get_or_create_container - unique to CLI container."""
|
||||
|
||||
def test_creates_new_when_none_exists(self, monkeypatch):
|
||||
"""get_or_create_container creates a new container when none exists."""
|
||||
import basic_memory.cli.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
container = get_or_create_container()
|
||||
assert container is not None
|
||||
assert isinstance(container, CliContainer)
|
||||
|
||||
def test_returns_existing_when_set(self, app_config, monkeypatch):
|
||||
"""get_or_create_container returns existing container if already set."""
|
||||
import basic_memory.cli.container as container_module
|
||||
|
||||
existing = CliContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
monkeypatch.setattr(container_module, "_container", existing)
|
||||
|
||||
result = get_or_create_container()
|
||||
assert result is existing
|
||||
|
||||
def test_sets_module_level_container(self, monkeypatch):
|
||||
"""get_or_create_container sets the module-level container."""
|
||||
import basic_memory.cli.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
container = get_or_create_container()
|
||||
|
||||
# Verify it was set at module level
|
||||
assert container_module._container is container
|
||||
# Verify get_container now works
|
||||
assert get_container() is container
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Tests for typed API clients."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from basic_memory.mcp.clients import (
|
||||
KnowledgeClient,
|
||||
SearchClient,
|
||||
MemoryClient,
|
||||
DirectoryClient,
|
||||
ResourceClient,
|
||||
ProjectClient,
|
||||
)
|
||||
|
||||
|
||||
class TestKnowledgeClient:
|
||||
"""Tests for KnowledgeClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "project-123")
|
||||
assert client.http_client is mock_http
|
||||
assert client.project_id == "project-123"
|
||||
assert client._base_path == "/v2/projects/project-123/knowledge"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity(self, monkeypatch):
|
||||
"""Test create_entity calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import knowledge as knowledge_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"permalink": "test",
|
||||
"title": "Test",
|
||||
"file_path": "test.md",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"observations": [],
|
||||
"relations": [],
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"updated_at": "2024-01-01T00:00:00",
|
||||
}
|
||||
|
||||
async def mock_call_post(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/knowledge/entities" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
result = await client.create_entity({"title": "Test"})
|
||||
assert result.title == "Test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_entity(self, monkeypatch):
|
||||
"""Test resolve_entity returns external_id."""
|
||||
from basic_memory.mcp.clients import knowledge as knowledge_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"external_id": "entity-uuid-123"}
|
||||
|
||||
async def mock_call_post(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/knowledge/resolve" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(knowledge_mod, "call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = KnowledgeClient(mock_http, "proj-123")
|
||||
result = await client.resolve_entity("my-note")
|
||||
assert result == "entity-uuid-123"
|
||||
|
||||
|
||||
class TestSearchClient:
|
||||
"""Tests for SearchClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = SearchClient(mock_http, "project-123")
|
||||
assert client.http_client is mock_http
|
||||
assert client.project_id == "project-123"
|
||||
assert client._base_path == "/v2/projects/project-123/search"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(self, monkeypatch):
|
||||
"""Test search calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import search as search_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"results": [],
|
||||
"current_page": 1,
|
||||
"page_size": 10,
|
||||
}
|
||||
|
||||
async def mock_call_post(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/search/" in url
|
||||
assert kwargs.get("params") == {"page": 1, "page_size": 10}
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(search_mod, "call_post", mock_call_post)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = SearchClient(mock_http, "proj-123")
|
||||
result = await client.search({"text": "query"}, page=1, page_size=10)
|
||||
assert result.results == []
|
||||
assert result.current_page == 1
|
||||
|
||||
|
||||
class TestMemoryClient:
|
||||
"""Tests for MemoryClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "project-123")
|
||||
assert client.http_client is mock_http
|
||||
assert client.project_id == "project-123"
|
||||
assert client._base_path == "/v2/projects/project-123/memory"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context(self, monkeypatch):
|
||||
"""Test build_context calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import memory as memory_mod
|
||||
from datetime import datetime
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"results": [],
|
||||
"metadata": {
|
||||
"depth": 1,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/memory/specs/search" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
result = await client.build_context("specs/search")
|
||||
assert result.results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent(self, monkeypatch):
|
||||
"""Test recent calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import memory as memory_mod
|
||||
from datetime import datetime
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"results": [],
|
||||
"metadata": {
|
||||
"depth": 2,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/memory/recent" in url
|
||||
params = kwargs.get("params", {})
|
||||
assert params.get("timeframe") == "7d"
|
||||
assert params.get("depth") == 2
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
result = await client.recent(timeframe="7d", depth=2)
|
||||
assert result.results == []
|
||||
assert result.metadata.depth == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_with_types(self, monkeypatch):
|
||||
"""Test recent with types filter."""
|
||||
from basic_memory.mcp.clients import memory as memory_mod
|
||||
from datetime import datetime
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"results": [],
|
||||
"metadata": {
|
||||
"depth": 1,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/memory/recent" in url
|
||||
params = kwargs.get("params", {})
|
||||
assert params.get("type") == "note,spec"
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(memory_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = MemoryClient(mock_http, "proj-123")
|
||||
result = await client.recent(types=["note", "spec"])
|
||||
assert result.results == []
|
||||
|
||||
|
||||
class TestDirectoryClient:
|
||||
"""Tests for DirectoryClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = DirectoryClient(mock_http, "project-123")
|
||||
assert client.http_client is mock_http
|
||||
assert client.project_id == "project-123"
|
||||
assert client._base_path == "/v2/projects/project-123/directory"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list(self, monkeypatch):
|
||||
"""Test list calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import directory as directory_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [{"name": "folder", "type": "directory"}]
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/directory/list" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(directory_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = DirectoryClient(mock_http, "proj-123")
|
||||
result = await client.list("/")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "folder"
|
||||
|
||||
|
||||
class TestResourceClient:
|
||||
"""Tests for ResourceClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = ResourceClient(mock_http, "project-123")
|
||||
assert client.http_client is mock_http
|
||||
assert client.project_id == "project-123"
|
||||
assert client._base_path == "/v2/projects/project-123/resource"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read(self, monkeypatch):
|
||||
"""Test read calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import resource as resource_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "# Note content"
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/v2/projects/proj-123/resource/entity-123" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(resource_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = ResourceClient(mock_http, "proj-123")
|
||||
result = await client.read("entity-123")
|
||||
assert result.text == "# Note content"
|
||||
|
||||
|
||||
class TestProjectClient:
|
||||
"""Tests for ProjectClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test client initialization."""
|
||||
mock_http = MagicMock()
|
||||
client = ProjectClient(mock_http)
|
||||
assert client.http_client is mock_http
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects(self, monkeypatch):
|
||||
"""Test list_projects calls correct endpoint."""
|
||||
from basic_memory.mcp.clients import project as project_mod
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"projects": [
|
||||
{
|
||||
"id": 1,
|
||||
"external_id": "uuid-123",
|
||||
"name": "test-project",
|
||||
"path": "/path/to/project",
|
||||
"is_default": True,
|
||||
}
|
||||
],
|
||||
"default_project": "test-project",
|
||||
}
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/projects/projects" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(project_mod, "call_get", mock_call_get)
|
||||
|
||||
mock_http = MagicMock()
|
||||
client = ProjectClient(mock_http)
|
||||
result = await client.list_projects()
|
||||
assert len(result.projects) == 1
|
||||
assert result.projects[0].name == "test-project"
|
||||
assert result.default_project == "test-project"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for MCP container composition root."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.mcp.container import (
|
||||
McpContainer,
|
||||
get_container,
|
||||
set_container,
|
||||
)
|
||||
from basic_memory.runtime import RuntimeMode
|
||||
|
||||
|
||||
class TestMcpContainer:
|
||||
"""Tests for McpContainer."""
|
||||
|
||||
def test_create_from_config(self, app_config):
|
||||
"""Container can be created from config manager."""
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.config == app_config
|
||||
assert container.mode == RuntimeMode.LOCAL
|
||||
|
||||
def test_should_sync_files_when_enabled_local_mode(self, app_config):
|
||||
"""Sync should be enabled in local mode when config says so."""
|
||||
app_config.sync_changes = True
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is True
|
||||
|
||||
def test_should_not_sync_files_when_disabled(self, app_config):
|
||||
"""Sync should be disabled when config says so."""
|
||||
app_config.sync_changes = False
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
def test_should_not_sync_files_in_test_mode(self, app_config):
|
||||
"""Sync should be disabled in test mode regardless of config."""
|
||||
app_config.sync_changes = True
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.TEST)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
def test_should_not_sync_files_in_cloud_mode(self, app_config):
|
||||
"""Sync should be disabled in cloud mode (cloud handles sync differently)."""
|
||||
app_config.sync_changes = True
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.CLOUD)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
|
||||
class TestSyncSkipReason:
|
||||
"""Tests for sync_skip_reason property."""
|
||||
|
||||
def test_skip_reason_in_test_mode(self, app_config):
|
||||
"""Returns test message when in test mode."""
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.TEST)
|
||||
assert container.sync_skip_reason == "Test environment detected"
|
||||
|
||||
def test_skip_reason_in_cloud_mode(self, app_config):
|
||||
"""Returns cloud message when in cloud mode."""
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.CLOUD)
|
||||
assert container.sync_skip_reason == "Cloud mode enabled"
|
||||
|
||||
def test_skip_reason_when_sync_disabled(self, app_config):
|
||||
"""Returns disabled message when sync is disabled."""
|
||||
app_config.sync_changes = False
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.sync_skip_reason == "Sync changes disabled"
|
||||
|
||||
def test_no_skip_reason_when_should_sync(self, app_config):
|
||||
"""Returns None when sync should run."""
|
||||
app_config.sync_changes = True
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.sync_skip_reason is None
|
||||
|
||||
|
||||
class TestContainerAccessors:
|
||||
"""Tests for container get/set functions."""
|
||||
|
||||
def test_get_container_raises_when_not_set(self, monkeypatch):
|
||||
"""get_container raises RuntimeError when container not initialized."""
|
||||
import basic_memory.mcp.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="MCP container not initialized"):
|
||||
get_container()
|
||||
|
||||
def test_set_and_get_container(self, app_config, monkeypatch):
|
||||
"""set_container allows get_container to return the container."""
|
||||
import basic_memory.mcp.container as container_module
|
||||
|
||||
container = McpContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
set_container(container)
|
||||
assert get_container() is container
|
||||
@@ -12,13 +12,20 @@ async def test_detect_cross_project_move_attempt_is_defensive_on_api_error(monke
|
||||
"""Cross-project detection should fail open (return None) if the projects API errors."""
|
||||
import importlib
|
||||
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
|
||||
# Mock ProjectClient to raise an exception on list_projects
|
||||
class MockProjectClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def list_projects(self, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(clients_mod, "ProjectClient", MockProjectClient)
|
||||
|
||||
move_note_module = importlib.import_module("basic_memory.mcp.tools.move_note")
|
||||
|
||||
async def boom(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(move_note_module, "call_get", boom)
|
||||
|
||||
result = await move_note_module._detect_cross_project_move_attempt(
|
||||
client=None,
|
||||
identifier="source/note",
|
||||
|
||||
@@ -33,19 +33,20 @@ async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch,
|
||||
)
|
||||
|
||||
import importlib
|
||||
|
||||
read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note")
|
||||
from basic_memory.mcp.tools.utils import resolve_entity_id as real_resolve_entity_id
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
OriginalKnowledgeClient = clients_mod.KnowledgeClient
|
||||
direct_identifier = memory_url_path("Fallback Title Note")
|
||||
|
||||
async def selective_resolve(client, project_id, identifier: str) -> int:
|
||||
if identifier == direct_identifier:
|
||||
raise RuntimeError("force direct lookup failure")
|
||||
return await real_resolve_entity_id(client, project_id, identifier)
|
||||
class SelectiveKnowledgeClient(OriginalKnowledgeClient):
|
||||
async def resolve_entity(self, identifier: str) -> int:
|
||||
# Fail on the direct identifier to force fallback to title search
|
||||
if identifier == direct_identifier:
|
||||
raise RuntimeError("force direct lookup failure")
|
||||
return await super().resolve_entity(identifier)
|
||||
|
||||
monkeypatch.setattr(read_note_module, "resolve_entity_id", selective_resolve)
|
||||
monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient)
|
||||
|
||||
content = await read_note.fn("Fallback Title Note", project=test_project.name)
|
||||
assert "fallback content" in content
|
||||
@@ -59,6 +60,8 @@ async def test_read_note_returns_related_results_when_text_search_finds_matches(
|
||||
import importlib
|
||||
|
||||
read_note_module = importlib.import_module("basic_memory.mcp.tools.read_note")
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
OriginalKnowledgeClient = clients_mod.KnowledgeClient
|
||||
|
||||
async def fake_search_notes_fn(*, query, search_type, **kwargs):
|
||||
if search_type == "title":
|
||||
@@ -88,10 +91,11 @@ async def test_read_note_returns_related_results_when_text_search_finds_matches(
|
||||
)
|
||||
|
||||
# Ensure direct resolution doesn't short-circuit the fallback logic.
|
||||
async def boom(*args, **kwargs):
|
||||
raise RuntimeError("force fallback")
|
||||
class FailingKnowledgeClient(OriginalKnowledgeClient):
|
||||
async def resolve_entity(self, identifier: str) -> int:
|
||||
raise RuntimeError("force fallback")
|
||||
|
||||
monkeypatch.setattr(read_note_module, "resolve_entity_id", boom)
|
||||
monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient)
|
||||
monkeypatch.setattr(read_note_module.search_notes, "fn", fake_search_notes_fn)
|
||||
|
||||
result = await read_note.fn("missing-note", project=test_project.name)
|
||||
|
||||
@@ -292,6 +292,7 @@ class TestSearchToolErrorHandling:
|
||||
import importlib
|
||||
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
|
||||
class StubProject:
|
||||
project_url = "http://test"
|
||||
@@ -302,11 +303,17 @@ class TestSearchToolErrorHandling:
|
||||
async def fake_get_active_project(*args, **kwargs):
|
||||
return StubProject()
|
||||
|
||||
async def fake_call_post(*args, **kwargs):
|
||||
raise Exception("syntax error")
|
||||
# Mock SearchClient to raise an exception
|
||||
class MockSearchClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def search(self, *args, **kwargs):
|
||||
raise Exception("syntax error")
|
||||
|
||||
monkeypatch.setattr(search_mod, "get_active_project", fake_get_active_project)
|
||||
monkeypatch.setattr(search_mod, "call_post", fake_call_post)
|
||||
# Patch at the clients module level where the import happens
|
||||
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes.fn(project="test-project", query="test query")
|
||||
assert isinstance(result, str)
|
||||
@@ -318,6 +325,7 @@ class TestSearchToolErrorHandling:
|
||||
import importlib
|
||||
|
||||
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
|
||||
clients_mod = importlib.import_module("basic_memory.mcp.clients")
|
||||
|
||||
class StubProject:
|
||||
project_url = "http://test"
|
||||
@@ -328,11 +336,17 @@ class TestSearchToolErrorHandling:
|
||||
async def fake_get_active_project(*args, **kwargs):
|
||||
return StubProject()
|
||||
|
||||
async def fake_call_post(*args, **kwargs):
|
||||
raise Exception("permission denied")
|
||||
# Mock SearchClient to raise a permission error
|
||||
class MockSearchClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def search(self, *args, **kwargs):
|
||||
raise Exception("permission denied")
|
||||
|
||||
monkeypatch.setattr(search_mod, "get_active_project", fake_get_active_project)
|
||||
monkeypatch.setattr(search_mod, "call_post", fake_call_post)
|
||||
# Patch at the clients module level where the import happens
|
||||
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
|
||||
|
||||
result = await search_mod.search_notes.fn(project="test-project", query="test query")
|
||||
assert isinstance(result, str)
|
||||
|
||||
@@ -107,6 +107,48 @@ async def test_init_search_index(search_repository, app_config):
|
||||
assert table_name == "search_index"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_search_index_preserves_data(search_repository, search_entity):
|
||||
"""Regression test: calling init_search_index() twice should preserve indexed data.
|
||||
|
||||
This test prevents regression of the bug fixed in PR #503 where
|
||||
init_search_index() was dropping existing data on every call due to
|
||||
an unconditional DROP TABLE statement.
|
||||
|
||||
The bug caused search to work immediately after creating notes, but
|
||||
return empty results after MCP server restarts (~30 minutes in Claude Desktop).
|
||||
"""
|
||||
# Create and index a search item
|
||||
search_row = SearchIndexRow(
|
||||
id=search_entity.id,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
title=search_entity.title,
|
||||
content_stems="regression test content for server restart",
|
||||
content_snippet="This content should persist across init_search_index calls",
|
||||
permalink=search_entity.permalink,
|
||||
file_path=search_entity.file_path,
|
||||
entity_id=search_entity.id,
|
||||
metadata={"entity_type": search_entity.entity_type},
|
||||
created_at=search_entity.created_at,
|
||||
updated_at=search_entity.updated_at,
|
||||
project_id=search_repository.project_id,
|
||||
)
|
||||
await search_repository.index_item(search_row)
|
||||
|
||||
# Verify it's searchable
|
||||
results = await search_repository.search(search_text="regression test")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == search_entity.title
|
||||
|
||||
# Re-initialize the search index (simulates MCP server restart)
|
||||
await search_repository.init_search_index()
|
||||
|
||||
# Verify data is still there after re-initialization
|
||||
results_after = await search_repository.search(search_text="regression test")
|
||||
assert len(results_after) == 1, "Search index data was lost after init_search_index()"
|
||||
assert results_after[0].id == search_entity.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_item(search_repository, search_entity):
|
||||
"""Test indexing an item with project_id."""
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for SyncCoordinator - centralized sync/watch lifecycle."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.sync.coordinator import SyncCoordinator, SyncStatus
|
||||
|
||||
|
||||
class TestSyncCoordinator:
|
||||
"""Test SyncCoordinator class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock config for testing."""
|
||||
return BasicMemoryConfig()
|
||||
|
||||
def test_initial_status(self, mock_config):
|
||||
"""Coordinator starts in NOT_STARTED state."""
|
||||
coordinator = SyncCoordinator(config=mock_config)
|
||||
assert coordinator.status == SyncStatus.NOT_STARTED
|
||||
assert coordinator.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_when_sync_disabled(self, mock_config):
|
||||
"""When should_sync is False, start() sets status to STOPPED."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=False,
|
||||
skip_reason="Test skip",
|
||||
)
|
||||
|
||||
await coordinator.start()
|
||||
|
||||
assert coordinator.status == SyncStatus.STOPPED
|
||||
assert coordinator.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_when_not_started(self, mock_config):
|
||||
"""Stop is safe to call when not started."""
|
||||
coordinator = SyncCoordinator(config=mock_config)
|
||||
|
||||
await coordinator.stop() # Should not raise
|
||||
|
||||
assert coordinator.status == SyncStatus.NOT_STARTED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_when_stopped(self, mock_config):
|
||||
"""Stop is idempotent when already stopped."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=False,
|
||||
)
|
||||
await coordinator.start() # Sets to STOPPED
|
||||
|
||||
await coordinator.stop() # Should not raise
|
||||
|
||||
assert coordinator.status == SyncStatus.STOPPED
|
||||
|
||||
def test_get_status_info(self, mock_config):
|
||||
"""get_status_info returns diagnostic info."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=True,
|
||||
skip_reason=None,
|
||||
)
|
||||
|
||||
info = coordinator.get_status_info()
|
||||
|
||||
assert info["status"] == "NOT_STARTED"
|
||||
assert info["should_sync"] is True
|
||||
assert info["skip_reason"] is None
|
||||
assert info["has_task"] is False
|
||||
|
||||
def test_get_status_info_with_skip_reason(self, mock_config):
|
||||
"""get_status_info includes skip reason."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=False,
|
||||
skip_reason="Test environment detected",
|
||||
)
|
||||
|
||||
info = coordinator.get_status_info()
|
||||
|
||||
assert info["should_sync"] is False
|
||||
assert info["skip_reason"] == "Test environment detected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_task(self, mock_config):
|
||||
"""When should_sync is True, start() creates a background task."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=True,
|
||||
)
|
||||
|
||||
# Mock initialize_file_sync to avoid actually starting sync
|
||||
# The import happens inside start(), so patch at the source module
|
||||
with patch(
|
||||
"basic_memory.services.initialization.initialize_file_sync",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
# Start coordinator
|
||||
await coordinator.start()
|
||||
|
||||
# Should be running with a task
|
||||
assert coordinator.status == SyncStatus.RUNNING
|
||||
assert coordinator.is_running is True
|
||||
assert coordinator._sync_task is not None
|
||||
|
||||
# Stop to clean up
|
||||
await coordinator.stop()
|
||||
|
||||
assert coordinator.status == SyncStatus.STOPPED
|
||||
assert coordinator._sync_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_already_running(self, mock_config):
|
||||
"""Starting when already running is a no-op."""
|
||||
coordinator = SyncCoordinator(
|
||||
config=mock_config,
|
||||
should_sync=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"basic_memory.services.initialization.initialize_file_sync",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
await coordinator.start()
|
||||
first_task = coordinator._sync_task
|
||||
|
||||
# Start again - should not create new task
|
||||
await coordinator.start()
|
||||
assert coordinator._sync_task is first_task
|
||||
|
||||
await coordinator.stop()
|
||||
@@ -8,6 +8,7 @@ import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
|
||||
from basic_memory.deps import get_project_config, get_project_id
|
||||
from basic_memory.deps.projects import validate_project_id
|
||||
from basic_memory.models.project import Project
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
|
||||
@@ -204,3 +205,28 @@ async def test_get_project_config_case_sensitivity(
|
||||
# All should resolve to the same project
|
||||
assert config1.name == config2.name == config3.name == "My Test Project"
|
||||
assert config1.home == config2.home == config3.home == Path("/my/test/project")
|
||||
|
||||
|
||||
# --- Tests for validate_project_id (v2 API) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_project_id_success(
|
||||
project_repository: ProjectRepository, test_project: Project
|
||||
):
|
||||
"""Test that validate_project_id returns project_id when project exists."""
|
||||
project_id = await validate_project_id(
|
||||
project_id=test_project.id, project_repository=project_repository
|
||||
)
|
||||
|
||||
assert project_id == test_project.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_project_id_not_found(project_repository: ProjectRepository):
|
||||
"""Test that validate_project_id raises HTTPException when project not found."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_project_id(project_id=99999, project_repository=project_repository)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "Project with ID 99999 not found" in exc_info.value.detail
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Tests for ProjectResolver - unified project resolution logic."""
|
||||
|
||||
import pytest
|
||||
from basic_memory.project_resolver import (
|
||||
ProjectResolver,
|
||||
ResolvedProject,
|
||||
ResolutionMode,
|
||||
)
|
||||
|
||||
|
||||
class TestProjectResolver:
|
||||
"""Test ProjectResolver class."""
|
||||
|
||||
def test_cloud_mode_requires_project(self):
|
||||
"""In cloud mode, project is required."""
|
||||
resolver = ProjectResolver(cloud_mode=True)
|
||||
with pytest.raises(ValueError, match="Project is required for cloud mode"):
|
||||
resolver.resolve(project=None)
|
||||
|
||||
def test_cloud_mode_with_explicit_project(self):
|
||||
"""In cloud mode, explicit project is accepted."""
|
||||
resolver = ProjectResolver(cloud_mode=True)
|
||||
result = resolver.resolve(project="my-project")
|
||||
|
||||
assert result.project == "my-project"
|
||||
assert result.mode == ResolutionMode.CLOUD_EXPLICIT
|
||||
assert result.is_resolved is True
|
||||
assert result.is_discovery_mode is False
|
||||
|
||||
def test_cloud_mode_discovery_allowed(self):
|
||||
"""In cloud mode with allow_discovery, None is acceptable."""
|
||||
resolver = ProjectResolver(cloud_mode=True)
|
||||
result = resolver.resolve(project=None, allow_discovery=True)
|
||||
|
||||
assert result.project is None
|
||||
assert result.mode == ResolutionMode.CLOUD_DISCOVERY
|
||||
assert result.is_resolved is False
|
||||
assert result.is_discovery_mode is True
|
||||
|
||||
def test_local_mode_env_constraint_priority(self, monkeypatch):
|
||||
"""Env constraint has highest priority in local mode."""
|
||||
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "constrained-project")
|
||||
resolver = ProjectResolver.from_env(
|
||||
cloud_mode=False,
|
||||
default_project_mode=True,
|
||||
default_project="default-project",
|
||||
)
|
||||
|
||||
# Even with explicit project and default, env constraint wins
|
||||
result = resolver.resolve(project="explicit-project")
|
||||
|
||||
assert result.project == "constrained-project"
|
||||
assert result.mode == ResolutionMode.ENV_CONSTRAINT
|
||||
assert result.is_resolved is True
|
||||
|
||||
def test_local_mode_explicit_project(self):
|
||||
"""Explicit project parameter has second priority."""
|
||||
resolver = ProjectResolver(
|
||||
cloud_mode=False,
|
||||
default_project_mode=True,
|
||||
default_project="default-project",
|
||||
)
|
||||
|
||||
result = resolver.resolve(project="explicit-project")
|
||||
|
||||
assert result.project == "explicit-project"
|
||||
assert result.mode == ResolutionMode.EXPLICIT
|
||||
|
||||
def test_local_mode_default_project(self):
|
||||
"""Default project is used when default_project_mode is true."""
|
||||
resolver = ProjectResolver(
|
||||
cloud_mode=False,
|
||||
default_project_mode=True,
|
||||
default_project="my-default",
|
||||
)
|
||||
|
||||
result = resolver.resolve(project=None)
|
||||
|
||||
assert result.project == "my-default"
|
||||
assert result.mode == ResolutionMode.DEFAULT
|
||||
|
||||
def test_local_mode_no_default_when_mode_disabled(self):
|
||||
"""Default project is NOT used when default_project_mode is false."""
|
||||
resolver = ProjectResolver(
|
||||
cloud_mode=False,
|
||||
default_project_mode=False,
|
||||
default_project="my-default",
|
||||
)
|
||||
|
||||
result = resolver.resolve(project=None)
|
||||
|
||||
assert result.project is None
|
||||
assert result.mode == ResolutionMode.NONE
|
||||
assert result.is_resolved is False
|
||||
|
||||
def test_local_mode_no_resolution_possible(self):
|
||||
"""When nothing is configured, resolution returns None."""
|
||||
resolver = ProjectResolver(cloud_mode=False)
|
||||
result = resolver.resolve(project=None)
|
||||
|
||||
assert result.project is None
|
||||
assert result.mode == ResolutionMode.NONE
|
||||
assert "default_project_mode is disabled" in result.reason
|
||||
|
||||
def test_require_project_success(self):
|
||||
"""require_project returns result when project resolved."""
|
||||
resolver = ProjectResolver(
|
||||
cloud_mode=False,
|
||||
default_project_mode=True,
|
||||
default_project="required-project",
|
||||
)
|
||||
|
||||
result = resolver.require_project()
|
||||
|
||||
assert result.project == "required-project"
|
||||
assert result.is_resolved is True
|
||||
|
||||
def test_require_project_raises_on_failure(self):
|
||||
"""require_project raises ValueError when not resolved."""
|
||||
resolver = ProjectResolver(cloud_mode=False, default_project_mode=False)
|
||||
|
||||
with pytest.raises(ValueError, match="No project specified"):
|
||||
resolver.require_project()
|
||||
|
||||
def test_require_project_custom_error_message(self):
|
||||
"""require_project uses custom error message."""
|
||||
resolver = ProjectResolver(cloud_mode=False, default_project_mode=False)
|
||||
|
||||
with pytest.raises(ValueError, match="Custom error message"):
|
||||
resolver.require_project(error_message="Custom error message")
|
||||
|
||||
def test_from_env_without_env_var(self, monkeypatch):
|
||||
"""from_env without BASIC_MEMORY_MCP_PROJECT set."""
|
||||
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
|
||||
resolver = ProjectResolver.from_env(
|
||||
cloud_mode=False,
|
||||
default_project_mode=True,
|
||||
default_project="test",
|
||||
)
|
||||
|
||||
assert resolver.constrained_project is None
|
||||
result = resolver.resolve(project="explicit")
|
||||
assert result.mode == ResolutionMode.EXPLICIT
|
||||
|
||||
def test_from_env_with_env_var(self, monkeypatch):
|
||||
"""from_env with BASIC_MEMORY_MCP_PROJECT set."""
|
||||
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "env-project")
|
||||
resolver = ProjectResolver.from_env()
|
||||
|
||||
assert resolver.constrained_project == "env-project"
|
||||
|
||||
|
||||
class TestResolvedProject:
|
||||
"""Test ResolvedProject dataclass."""
|
||||
|
||||
def test_is_resolved_true(self):
|
||||
"""is_resolved returns True when project is set."""
|
||||
result = ResolvedProject(
|
||||
project="test",
|
||||
mode=ResolutionMode.EXPLICIT,
|
||||
reason="test",
|
||||
)
|
||||
assert result.is_resolved is True
|
||||
|
||||
def test_is_resolved_false(self):
|
||||
"""is_resolved returns False when project is None."""
|
||||
result = ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.NONE,
|
||||
reason="test",
|
||||
)
|
||||
assert result.is_resolved is False
|
||||
|
||||
def test_is_discovery_mode_cloud(self):
|
||||
"""is_discovery_mode is True for CLOUD_DISCOVERY."""
|
||||
result = ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.CLOUD_DISCOVERY,
|
||||
reason="test",
|
||||
)
|
||||
assert result.is_discovery_mode is True
|
||||
|
||||
def test_is_discovery_mode_none(self):
|
||||
"""is_discovery_mode is True for NONE with no project."""
|
||||
result = ResolvedProject(
|
||||
project=None,
|
||||
mode=ResolutionMode.NONE,
|
||||
reason="test",
|
||||
)
|
||||
assert result.is_discovery_mode is True
|
||||
|
||||
def test_is_discovery_mode_false(self):
|
||||
"""is_discovery_mode is False when project is resolved."""
|
||||
result = ResolvedProject(
|
||||
project="test",
|
||||
mode=ResolutionMode.EXPLICIT,
|
||||
reason="test",
|
||||
)
|
||||
assert result.is_discovery_mode is False
|
||||
|
||||
def test_frozen_dataclass(self):
|
||||
"""ResolvedProject is immutable."""
|
||||
result = ResolvedProject(
|
||||
project="test",
|
||||
mode=ResolutionMode.EXPLICIT,
|
||||
reason="test",
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
result.project = "changed" # type: ignore
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for runtime mode resolution."""
|
||||
|
||||
|
||||
from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
|
||||
|
||||
|
||||
class TestRuntimeMode:
|
||||
"""Tests for RuntimeMode enum."""
|
||||
|
||||
def test_local_mode_properties(self):
|
||||
mode = RuntimeMode.LOCAL
|
||||
assert mode.is_local is True
|
||||
assert mode.is_cloud is False
|
||||
assert mode.is_test is False
|
||||
|
||||
def test_cloud_mode_properties(self):
|
||||
mode = RuntimeMode.CLOUD
|
||||
assert mode.is_local is False
|
||||
assert mode.is_cloud is True
|
||||
assert mode.is_test is False
|
||||
|
||||
def test_test_mode_properties(self):
|
||||
mode = RuntimeMode.TEST
|
||||
assert mode.is_local is False
|
||||
assert mode.is_cloud is False
|
||||
assert mode.is_test is True
|
||||
|
||||
|
||||
class TestResolveRuntimeMode:
|
||||
"""Tests for resolve_runtime_mode function."""
|
||||
|
||||
def test_resolves_to_test_when_test_env(self):
|
||||
"""Test environment takes precedence over cloud mode."""
|
||||
mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=True)
|
||||
assert mode == RuntimeMode.TEST
|
||||
|
||||
def test_resolves_to_cloud_when_enabled(self):
|
||||
"""Cloud mode is used when enabled and not in test env."""
|
||||
mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=False)
|
||||
assert mode == RuntimeMode.CLOUD
|
||||
|
||||
def test_resolves_to_local_by_default(self):
|
||||
"""Local mode is the default when no other modes apply."""
|
||||
mode = resolve_runtime_mode(cloud_mode_enabled=False, is_test_env=False)
|
||||
assert mode == RuntimeMode.LOCAL
|
||||
|
||||
def test_test_env_overrides_cloud_mode(self):
|
||||
"""Test environment should override cloud mode."""
|
||||
# When both are enabled, test takes precedence
|
||||
mode = resolve_runtime_mode(cloud_mode_enabled=True, is_test_env=True)
|
||||
assert mode == RuntimeMode.TEST
|
||||
assert mode.is_test is True
|
||||
assert mode.is_cloud is False
|
||||
Reference in New Issue
Block a user