Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,11 +685,8 @@ def add_project(self, name: str, path: str) -> ProjectConfig:
if project_name: # pragma: no cover
raise ValueError(f"Project '{name}' already exists")

# Ensure the path exists
project_path = Path(path)
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover

# Load config, modify it, and save it
project_path = Path(path)
config = self.load_config()
config.projects[name] = ProjectEntry(path=str(project_path))
self.save_config(config)
Expand Down
11 changes: 9 additions & 2 deletions src/basic_memory/deps/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import asyncio
import os
from pathlib import Path
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol

from fastapi import Depends
Expand Down Expand Up @@ -549,9 +550,15 @@ async def _reindex_project(**_: Any) -> None:

async def get_project_service(
project_repository: ProjectRepositoryDep,
app_config: AppConfigDep,
) -> ProjectService:
"""Create ProjectService with repository."""
return ProjectService(repository=project_repository)
"""Create ProjectService with repository and a system-level FileService for directory operations."""
# A system-level FileService for project directory creation (no project-specific base_path needed).
# ensure_directory() accepts absolute paths and ignores base_path for those, so Path.home() is safe.
entity_parser = EntityParser(Path.home())
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
file_service = FileService(Path.home(), markdown_processor, app_config=app_config)
return ProjectService(repository=project_repository, file_service=file_service)


ProjectServiceDep = Annotated[ProjectService, Depends(get_project_service)]
Expand Down
7 changes: 6 additions & 1 deletion src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ async def list_memory_projects(

result = "Available projects:\n"
for project in project_list.projects:
result += f"• {project.name}\n"
label = (
f"{project.display_name} ({project.name})"
if project.display_name
else project.name
)
result += f"• {label}\n"

result += "\n" + "─" * 40 + "\n"
result += "Next: Ask which project to use for this session.\n"
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/repository/fastembed_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError

if TYPE_CHECKING:
from fastembed import TextEmbedding # pragma: no cover
from fastembed import TextEmbedding # type: ignore[import-not-found] # pragma: no cover


class FastEmbedEmbeddingProvider(EmbeddingProvider):
Expand Down Expand Up @@ -42,7 +42,7 @@ async def _load_model(self) -> "TextEmbedding":

def _create_model() -> "TextEmbedding":
try:
from fastembed import TextEmbedding
from fastembed import TextEmbedding # type: ignore[import-not-found]
except (
ImportError
) as exc: # pragma: no cover - exercised via tests with monkeypatch
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/repository/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def _get_client(self) -> Any:
return self._client

try:
from openai import AsyncOpenAI
from openai import AsyncOpenAI # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover - covered via monkeypatch tests
raise SemanticDependenciesMissingError(
"OpenAI dependency is missing. "
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/repository/sqlite_search_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ async def _ensure_sqlite_vec_loaded(self, session) -> None:
pass

try:
import sqlite_vec
import sqlite_vec # type: ignore[import-not-found]
except ImportError as exc:
raise SemanticDependenciesMissingError(
"sqlite-vec package is missing. "
Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/schemas/project_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ class ProjectItem(BaseModel):
name: str
path: str
is_default: bool = False
# Optional metadata injected by cloud hosting layer (not stored in DB)
display_name: Optional[str] = None
is_private: bool = False

@property
def permalink(self) -> str: # pragma: no cover
Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/services/file_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class FileService:
def __init__(
self,
base_path: Path,
markdown_processor: MarkdownProcessor,
markdown_processor: Optional[MarkdownProcessor] = None,
max_concurrent_files: int = 10,
app_config: Optional["BasicMemoryConfig"] = None,
):
Expand Down Expand Up @@ -79,6 +79,10 @@ async def read_entity_content(self, entity: EntityModel) -> str:
"""
logger.debug(f"Reading entity content, entity_id={entity.id}, permalink={entity.permalink}")

# markdown_processor is required for entity content reads — fail fast if not configured
if self.markdown_processor is None:
raise ValueError("markdown_processor is required for read_entity_content")

file_path = self.get_entity_path(entity)
markdown = await self.markdown_processor.read_file(file_path)
return markdown.content or ""
Expand Down
30 changes: 26 additions & 4 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import shutil
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Sequence
from typing import TYPE_CHECKING, Dict, Optional, Sequence


from loguru import logger
Expand All @@ -30,16 +30,22 @@
)
from basic_memory.utils import generate_permalink

if TYPE_CHECKING: # pragma: no cover
from basic_memory.services.file_service import FileService


class ProjectService:
"""Service for managing Basic Memory projects."""

repository: ProjectRepository

def __init__(self, repository: ProjectRepository):
def __init__(
self, repository: ProjectRepository, file_service: Optional["FileService"] = None
):
"""Initialize the project service."""
super().__init__()
self.repository = repository
self.file_service = file_service

@property
def config_manager(self) -> ConfigManager:
Expand Down Expand Up @@ -205,6 +211,16 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
f"Projects cannot share directory trees."
)

# Ensure the project directory exists on disk.
# Trigger: project_root not set means local filesystem mode (not S3/cloud)
# Why: FileService (or future S3FileService) provides cloud-compatible directory creation;
# direct Path.mkdir() bypasses this abstraction
# Outcome: directory exists before config/DB entries are written
if not self.config_manager.config.project_root:
if self.file_service is None:
raise ValueError("file_service is required for local project directory creation")
await self.file_service.ensure_directory(Path(resolved_path))

# First add to config file (this validates project uniqueness and keeps
# config + database aligned for all backends).
self.config_manager.add_project(name, resolved_path)
Expand Down Expand Up @@ -459,8 +475,14 @@ async def move_project(self, name: str, new_path: str) -> None:
if name not in self.config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")

# Create the new directory if it doesn't exist
Path(resolved_path).mkdir(parents=True, exist_ok=True)
# Create the new directory if it doesn't exist (skip in cloud mode where storage is S3)
# Trigger: project_root not set means local filesystem mode
# Why: FileService (or future S3FileService) provides cloud-compatible directory creation
# Outcome: destination directory exists before config/DB are updated
if not self.config_manager.config.project_root:
if self.file_service is None:
raise ValueError("file_service is required for local project directory creation")
await self.file_service.ensure_directory(Path(resolved_path))

# Update in configuration
config = self.config_manager.load_config()
Expand Down
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,10 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
@pytest_asyncio.fixture
async def project_service(
project_repository: ProjectRepository,
file_service: FileService,
) -> ProjectService:
"""Create ProjectService with repository."""
return ProjectService(repository=project_repository)
"""Create ProjectService with repository and file service for directory operations."""
return ProjectService(repository=project_repository, file_service=file_service)


@pytest_asyncio.fixture
Expand Down
69 changes: 69 additions & 0 deletions tests/mcp/test_tool_project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,75 @@ async def test_list_memory_projects_unconstrained(app, test_project):
assert f"• {test_project.name}" in result


@pytest.mark.asyncio
async def test_list_memory_projects_shows_display_name(app, client, test_project):
"""When a project has display_name set, list_memory_projects shows 'display_name (name)' format."""
# Inject display_name into the project list response by patching the API response.
# In production, the cloud proxy adds display_name to the JSON before deserialization.
from unittest.mock import AsyncMock, patch
from basic_memory.schemas.project_info import ProjectItem, ProjectList

mock_project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="private-fb83af23",
path="/tmp/private",
is_default=False,
display_name="My Notes",
is_private=True,
)
regular_project = ProjectItem(
id=2,
external_id="00000000-0000-0000-0000-000000000002",
name="main",
path="/tmp/main",
is_default=True,
)
mock_list = ProjectList(
projects=[regular_project, mock_project],
default_project="main",
)

with patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()

# Regular project shows just the name
assert "• main\n" in result
# Private project shows display_name with slug in parentheses
assert "• My Notes (private-fb83af23)" in result


@pytest.mark.asyncio
async def test_list_memory_projects_no_display_name_shows_name_only(app, client, test_project):
"""When a project has no display_name, list_memory_projects shows just the name."""
from unittest.mock import AsyncMock, patch
from basic_memory.schemas.project_info import ProjectItem, ProjectList

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="my-project",
path="/tmp/my-project",
is_default=True,
)
mock_list = ProjectList(projects=[project], default_project="my-project")

with patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()

assert "• my-project\n" in result
# Should NOT have parenthetical format
assert "(" not in result.split("• my-project")[1].split("\n")[0]


@pytest.mark.asyncio
async def test_list_memory_projects_constrained_env(monkeypatch, app, test_project):
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
Expand Down
100 changes: 100 additions & 0 deletions tests/schemas/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,106 @@ class TestModel(BaseModel):
assert time_diff < 3600, f"'today' and '1d' should be similar times, diff: {time_diff}s"


class TestProjectItemSchema:
"""Test ProjectItem schema with optional cloud-injected fields."""

def test_project_item_defaults(self):
"""ProjectItem has sensible defaults for cloud-injected fields."""
from basic_memory.schemas.project_info import ProjectItem

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="main",
path="/tmp/main",
)
assert project.display_name is None
assert project.is_private is False
assert project.is_default is False

def test_project_item_with_display_name(self):
"""ProjectItem accepts display_name from cloud proxy enrichment."""
from basic_memory.schemas.project_info import ProjectItem

project = ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="private-fb83af23",
path="/tmp/private",
display_name="My Notes",
is_private=True,
)
assert project.display_name == "My Notes"
assert project.is_private is True
assert project.name == "private-fb83af23"

def test_project_item_deserialization_from_json(self):
"""ProjectItem correctly deserializes display_name and is_private from JSON.

This is the actual path: the cloud proxy enriches the JSON response from
basic-memory API, and the MCP tools deserialize it back into ProjectItem.
"""
from basic_memory.schemas.project_info import ProjectItem

json_data = {
"id": 1,
"external_id": "00000000-0000-0000-0000-000000000001",
"name": "private-fb83af23",
"path": "/tmp/private",
"is_default": False,
"display_name": "My Notes",
"is_private": True,
}
project = ProjectItem.model_validate(json_data)
assert project.display_name == "My Notes"
assert project.is_private is True

def test_project_item_deserialization_without_cloud_fields(self):
"""ProjectItem works when cloud fields are absent (non-cloud usage)."""
from basic_memory.schemas.project_info import ProjectItem

json_data = {
"id": 1,
"external_id": "00000000-0000-0000-0000-000000000001",
"name": "main",
"path": "/tmp/main",
"is_default": True,
}
project = ProjectItem.model_validate(json_data)
assert project.display_name is None
assert project.is_private is False

def test_project_list_with_mixed_projects(self):
"""ProjectList can contain a mix of regular and private projects."""
from basic_memory.schemas.project_info import ProjectItem, ProjectList

projects = ProjectList(
projects=[
ProjectItem(
id=1,
external_id="00000000-0000-0000-0000-000000000001",
name="main",
path="/tmp/main",
is_default=True,
),
ProjectItem(
id=2,
external_id="00000000-0000-0000-0000-000000000002",
name="private-fb83af23",
path="/tmp/private",
display_name="My Notes",
is_private=True,
),
],
default_project="main",
)
assert len(projects.projects) == 2
assert projects.projects[0].display_name is None
assert projects.projects[0].is_private is False
assert projects.projects[1].display_name == "My Notes"
assert projects.projects[1].is_private is True


class TestObservationContentLength:
"""Test observation content length validation matches DB schema."""

Expand Down
Loading
Loading