feat: v0.13.0 pre (#122)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-06-03 01:00:40 -05:00
committed by GitHub
parent 5ec4087f7c
commit 634486107b
116 changed files with 13122 additions and 2147 deletions
+7 -1
View File
@@ -1,3 +1,9 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.12.3"
try:
from importlib.metadata import version
__version__ = version("basic-memory")
except Exception: # pragma: no cover
# Fallback if package not installed (e.g., during development)
__version__ = "0.0.0" # pragma: no cover
@@ -56,11 +56,6 @@ def upgrade() -> None:
);
""")
# Print instruction to manually reindex after migration
print("\n------------------------------------------------------------------")
print("IMPORTANT: After migration completes, manually run the reindex command:")
print("basic-memory sync")
print("------------------------------------------------------------------\n")
def downgrade() -> None:
+5 -2
View File
@@ -60,15 +60,18 @@ app = FastAPI(
# Include routers
app.include_router(knowledge.router, prefix="/{project}")
app.include_router(management.router, prefix="/{project}")
app.include_router(memory.router, prefix="/{project}")
app.include_router(resource.router, prefix="/{project}")
app.include_router(search.router, prefix="/{project}")
app.include_router(project.router, prefix="/{project}")
app.include_router(project.project_router, prefix="/{project}")
app.include_router(directory_router.router, prefix="/{project}")
app.include_router(prompt_router.router, prefix="/{project}")
app.include_router(importer_router.router, prefix="/{project}")
# Project resource router works accross projects
app.include_router(project.project_resource_router)
app.include_router(management.router)
# Auth routes are handled by FastMCP automatically when auth is enabled
@@ -1,6 +1,8 @@
"""Router for directory tree operations."""
from fastapi import APIRouter
from typing import List, Optional
from fastapi import APIRouter, Query
from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
from basic_memory.schemas.directory import DirectoryNode
@@ -27,3 +29,35 @@ async def get_directory_tree(
# Return the hierarchical tree
return tree
@router.get("/list", response_model=List[DirectoryNode])
async def list_directory(
directory_service: DirectoryServiceDep,
project_id: ProjectIdDep,
dir_name: str = Query("/", description="Directory path to list"),
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
file_name_glob: Optional[str] = Query(
None, description="Glob pattern for filtering file names"
),
):
"""List directory contents with filtering and depth control.
Args:
directory_service: Service for directory operations
project_id: ID of the current project
dir_name: Directory path to list (default: root "/")
depth: Recursion depth (1-10, default: 1 for immediate children only)
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
Returns:
List of DirectoryNode objects matching the criteria
"""
# Get directory listing with filtering
nodes = await directory_service.list_directory(
dir_name=dir_name,
depth=depth,
file_name_glob=file_name_glob,
)
return nodes
+110 -24
View File
@@ -10,6 +10,10 @@ from basic_memory.deps import (
get_search_service,
SearchServiceDep,
LinkResolverDep,
ProjectPathDep,
FileServiceDep,
ProjectConfigDep,
AppConfigDep,
)
from basic_memory.schemas import (
EntityListResponse,
@@ -17,6 +21,7 @@ from basic_memory.schemas import (
DeleteEntitiesResponse,
DeleteEntitiesRequest,
)
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@@ -43,41 +48,31 @@ async def create_entity(
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="create_entity",
title=result.title,
permalink=result.permalink,
status_code=201,
f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
)
return result
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
async def create_or_update_entity(
project: ProjectPathDep,
permalink: Permalink,
data: Entity,
response: Response,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
file_service: FileServiceDep,
) -> EntityResponse:
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
logger.info(
"API request",
endpoint="create_or_update_entity",
permalink=permalink,
entity_type=data.entity_type,
title=data.title,
f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
)
# Validate permalink matches
if data.permalink != permalink:
logger.warning(
"API validation error",
endpoint="create_or_update_entity",
permalink=permalink,
data_permalink=data.permalink,
error="Permalink mismatch",
f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
)
raise HTTPException(
status_code=400,
@@ -93,16 +88,107 @@ async def create_or_update_entity(
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="create_or_update_entity",
title=result.title,
permalink=result.permalink,
created=created,
status_code=response.status_code,
f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
)
return result
@router.patch("/entities/{identifier:path}", response_model=EntityResponse)
async def edit_entity(
identifier: str,
data: EditEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
This endpoint allows for targeted edits without requiring the full entity content.
"""
logger.info(
f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
)
try:
# Edit the entity using the service
entity = await entity_service.edit_entity(
identifier=identifier,
operation=data.operation,
content=data.content,
section=data.section,
find_text=data.find_text,
expected_replacements=data.expected_replacements,
)
# Reindex the updated entity
await search_service.index_entity(entity, background_tasks=background_tasks)
# Return the updated entity response
result = EntityResponse.model_validate(entity)
logger.info(
"API response",
endpoint="edit_entity",
identifier=identifier,
operation=data.operation,
permalink=result.permalink,
status_code=200,
)
return result
except Exception as e:
logger.error(f"Error editing entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/move")
async def move_entity(
data: MoveEntityRequest,
background_tasks: BackgroundTasks,
entity_service: EntityServiceDep,
project_config: ProjectConfigDep,
app_config: AppConfigDep,
search_service: SearchServiceDep,
) -> EntityResponse:
"""Move an entity to a new file location with project consistency.
This endpoint moves a note to a different path while maintaining project
consistency and optionally updating permalinks based on configuration.
"""
logger.info(
f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
)
try:
# Move the entity using the service
moved_entity = await entity_service.move_entity(
identifier=data.identifier,
destination_path=data.destination_path,
project_config=project_config,
app_config=app_config,
)
# Get the moved entity to reindex it
entity = await entity_service.link_resolver.resolve_link(data.destination_path)
if entity:
await search_service.index_entity(entity, background_tasks=background_tasks)
logger.info(
"API response",
endpoint="move_entity",
identifier=data.identifier,
destination=data.destination_path,
status_code=200,
)
result = EntityResponse.model_validate(moved_entity)
return result
except Exception as e:
logger.error(f"Error moving entity: {e}")
raise HTTPException(status_code=400, detail=str(e))
## Read endpoints
@@ -164,8 +250,8 @@ async def delete_entity(
# Delete the entity
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
# Remove from search index
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
# Remove from search index (entity, observations, and relations)
background_tasks.add_task(search_service.handle_delete, entity)
result = DeleteEntitiesResponse(deleted=deleted)
return result
@@ -188,4 +274,4 @@ async def delete_entities(
background_tasks.add_task(search_service.delete_by_permalink, permalink)
result = DeleteEntitiesResponse(deleted=deleted)
return result
return result
+86 -91
View File
@@ -8,17 +8,18 @@ from basic_memory.schemas import ProjectInfoResponse
from basic_memory.schemas.project_info import (
ProjectList,
ProjectItem,
ProjectSwitchRequest,
ProjectInfoRequest,
ProjectStatusResponse,
ProjectWatchStatus,
)
# Define the router - we'll combine stats and project operations
router = APIRouter(prefix="/project", tags=["project"])
# Router for resources in a specific project
project_router = APIRouter(prefix="/project", tags=["project"])
# Router for managing project resources
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
# Get project information (moved from project_info_router.py)
@router.get("/info", response_model=ProjectInfoResponse)
@project_router.get("/info", response_model=ProjectInfoResponse)
async def get_project_info(
project_service: ProjectServiceDep,
) -> ProjectInfoResponse:
@@ -26,8 +27,49 @@ async def get_project_info(
return await project_service.get_project_info()
# Update a project
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
project_name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information in configuration and database.
Args:
project_name: The name of the project to update
path: Optional new path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
"""
try: # pragma: no cover
# Get original project info for the response
old_project = ProjectItem(
name=project_name,
path=project_service.projects.get(project_name, ""),
)
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
# Get updated project info
updated_path = path if path else project_service.projects.get(project_name, "")
return ProjectStatusResponse(
message=f"Project '{project_name}' updated successfully",
status="success",
default=(project_name == project_service.default_project),
old_project=old_project,
new_project=ProjectItem(name=project_name, path=updated_path),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# List all available projects
@router.get("/projects", response_model=ProjectList)
@project_resource_router.get("/projects", response_model=ProjectList)
async def list_projects(
project_service: ProjectServiceDep,
) -> ProjectList:
@@ -36,32 +78,28 @@ async def list_projects(
Returns:
A list of all projects with metadata
"""
projects_dict = project_service.projects
projects = await project_service.list_projects()
default_project = project_service.default_project
current_project = project_service.current_project
project_items = []
for name, path in projects_dict.items():
project_items.append(
ProjectItem(
name=name,
path=path,
is_default=(name == default_project),
is_current=(name == current_project),
)
project_items = [
ProjectItem(
name=project.name,
path=project.path,
is_default=project.is_default or False,
)
for project in projects
]
return ProjectList(
projects=project_items,
default_project=default_project,
current_project=current_project,
)
# Add a new project
@router.post("/projects", response_model=ProjectStatusResponse)
@project_resource_router.post("/projects", response_model=ProjectStatusResponse)
async def add_project(
project_data: ProjectSwitchRequest,
project_data: ProjectInfoRequest,
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
"""Add a new project to configuration and database.
@@ -82,10 +120,8 @@ async def add_project(
message=f"Project '{project_data.name}' added successfully",
status="success",
default=project_data.set_default,
new_project=ProjectWatchStatus(
name=project_data.name,
path=project_data.path,
watch_status=None,
new_project=ProjectItem(
name=project_data.name, path=project_data.path, is_default=project_data.set_default
),
)
except ValueError as e: # pragma: no cover
@@ -93,7 +129,7 @@ async def add_project(
# Remove a project
@router.delete("/projects/{name}", response_model=ProjectStatusResponse)
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
async def remove_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to remove"),
@@ -106,28 +142,26 @@ async def remove_project(
Returns:
Response confirming the project was removed
"""
try: # pragma: no cover
# Get project info before removal for the response
old_project = ProjectWatchStatus(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
)
try:
old_project = await project_service.get_project(name)
if not old_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
await project_service.remove_project(name)
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
return ProjectStatusResponse(
message=f"Project '{name}' removed successfully",
status="success",
default=False,
old_project=old_project,
old_project=ProjectItem(name=old_project.name, path=old_project.path),
new_project=None,
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Set a project as default
@router.put("/projects/{name}/default", response_model=ProjectStatusResponse)
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
async def set_default_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to set as default"),
@@ -140,78 +174,39 @@ async def set_default_project(
Returns:
Response confirming the project was set as default
"""
try: # pragma: no cover
try:
# Get the old default project
old_default = project_service.default_project
old_project = None
if old_default != name:
old_project = ProjectWatchStatus(
name=old_default,
path=project_service.projects.get(old_default, ""),
watch_status=None,
default_name = project_service.default_project
default_project = await project_service.get_project(default_name)
if not default_project: # pragma: no cover
raise HTTPException( # pragma: no cover
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
)
# get the new project
new_default_project = await project_service.get_project(name)
if not new_default_project: # pragma: no cover
raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
await project_service.set_default_project(name)
return ProjectStatusResponse(
message=f"Project '{name}' set as default successfully",
status="success",
default=True,
old_project=old_project,
new_project=ProjectWatchStatus(
old_project=ProjectItem(name=default_name, path=default_project.path),
new_project=ProjectItem(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
path=new_default_project.path,
is_default=True,
),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Update a project
@router.patch("/projects/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information in configuration and database.
Args:
name: The name of the project to update
path: Optional new path for the project
is_active: Optional status update for the project
Returns:
Response confirming the project was updated
"""
try: # pragma: no cover
# Get original project info for the response
old_project = ProjectWatchStatus(
name=name,
path=project_service.projects.get(name, ""),
watch_status=None,
)
await project_service.update_project(name, updated_path=path, is_active=is_active)
# Get updated project info
updated_path = path if path else project_service.projects.get(name, "")
return ProjectStatusResponse(
message=f"Project '{name}' updated successfully",
status="success",
default=(name == project_service.default_project),
old_project=old_project,
new_project=ProjectWatchStatus(name=name, path=updated_path, watch_status=None),
)
except ValueError as e: # pragma: no cover
raise HTTPException(status_code=400, detail=str(e))
# Synchronize projects between config and database
@router.post("/sync", response_model=ProjectStatusResponse)
@project_resource_router.post("/sync", response_model=ProjectStatusResponse)
async def synchronize_projects(
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
+18 -19
View File
@@ -2,6 +2,9 @@ from typing import Optional
import typer
from basic_memory.config import get_project_config
from basic_memory.mcp.project_session import session
def version_callback(value: bool) -> None:
"""Show version and exit."""
@@ -39,25 +42,6 @@ def app_callback(
) -> None:
"""Basic Memory - Local-first personal knowledge management."""
# We use the project option to set the BASIC_MEMORY_PROJECT environment variable
# The config module will pick this up when loading
if project: # pragma: no cover
import os
import importlib
from basic_memory import config as config_module
# Set the environment variable
os.environ["BASIC_MEMORY_PROJECT"] = project
# Reload the config module to pick up the new project
importlib.reload(config_module)
# Update the local reference
global app_config
from basic_memory.config import app_config as new_config
app_config = new_config
# Run initialization for every command unless --version was specified
if not version and ctx.invoked_subcommand is not None:
from basic_memory.config import app_config
@@ -65,6 +49,21 @@ def app_callback(
ensure_initialization(app_config)
# Initialize MCP session with the specified project or default
if project: # pragma: no cover
# Use the project specified via --project flag
current_project_config = get_project_config(project)
session.set_current_project(current_project_config.name)
# Update the global config to use this project
from basic_memory.config import update_current_project
update_current_project(project)
else:
# Use the default project
current_project = app_config.default_project
session.set_current_project(current_project)
# Register sub-command groups
import_app = typer.Typer(help="Import data from various sources")
+4 -37
View File
@@ -10,7 +10,8 @@ from rich.table import Table
from basic_memory.cli.app import app
from basic_memory.config import config
from basic_memory.mcp.tools.project_info import project_info
from basic_memory.mcp.project_session import session
from basic_memory.mcp.resources.project_info import project_info
import json
from datetime import datetime
@@ -35,7 +36,7 @@ def format_path(path: str) -> str:
"""Format a path for display, using ~ for home directory."""
home = str(Path.home())
if path.startswith(home):
return path.replace(home, "~", 1)
return path.replace(home, "~", 1) # pragma: no cover
return path
@@ -58,7 +59,7 @@ def list_projects() -> None:
for project in result.projects:
is_default = "" if project.is_default else ""
is_active = "" if project.is_current else ""
is_active = "" if session.get_current_project() == project.name else ""
table.add_row(project.name, format_path(project.path), is_default, is_active)
console.print(table)
@@ -148,40 +149,6 @@ def set_default_project(
console.print("[green]Project activated for current session[/green]")
@project_app.command("current")
def show_current_project() -> None:
"""Show the current project."""
# Use API to get current project
project_url = config.project_url
try:
response = asyncio.run(call_get(client, f"{project_url}/project/projects"))
result = ProjectList.model_validate(response.json())
# Find the current project from the API response
current_project = result.current_project
default_project = result.default_project
# Find the project details in the list
for project in result.projects:
if project.name == current_project:
console.print(f"Current project: [cyan]{project.name}[/cyan]")
console.print(f"Path: [green]{format_path(project.path)}[/green]")
# Use app_config for database_path, not project config
from basic_memory.config import app_config
console.print(
f"Database: [blue]{format_path(str(app_config.app_database_path))}[/blue]"
)
console.print(f"Default project: [yellow]{default_project}[/yellow]")
break
except Exception as e:
console.print(f"[red]Error getting current project: {str(e)}[/red]")
console.print("[yellow]Note: Make sure the Basic Memory server is running.[/yellow]")
raise typer.Exit(1)
@project_app.command("sync")
def synchronize_projects() -> None:
"""Synchronize projects between configuration file and database."""
+46 -12
View File
@@ -9,10 +9,12 @@ from typing import Any, Dict, Literal, Optional, List
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from setuptools.command.setopt import config_file
import basic_memory
from basic_memory.utils import setup_logging, generate_permalink
DATABASE_NAME = "memory.db"
APP_DATABASE_NAME = "memory.db" # Using the same name but in the app directory
DATA_DIR_NAME = ".basic-memory"
@@ -147,7 +149,11 @@ class ConfigManager:
def __init__(self) -> None:
"""Initialize the configuration manager."""
self.config_dir = Path.home() / DATA_DIR_NAME
home = os.getenv("HOME", Path.home())
if isinstance(home, str):
home = Path(home)
self.config_dir = home / DATA_DIR_NAME
self.config_file = self.config_dir / CONFIG_FILE_NAME
# Ensure config directory exists
@@ -156,9 +162,6 @@ class ConfigManager:
# Load or create configuration
self.config = self.load_config()
# Current project context for the session
self.current_project_id: int
def load_config(self) -> BasicMemoryConfig:
"""Load configuration from file or create default."""
if self.config_file.exists():
@@ -177,7 +180,7 @@ class ConfigManager:
def save_config(self, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
try:
self.config_file.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
@@ -192,7 +195,7 @@ class ConfigManager:
"""Get the default project name."""
return self.config.default_project
def add_project(self, name: str, path: str) -> None:
def add_project(self, name: str, path: str) -> ProjectConfig:
"""Add a new project to the configuration."""
if name in self.config.projects: # pragma: no cover
raise ValueError(f"Project '{name}' already exists")
@@ -203,6 +206,7 @@ class ConfigManager:
self.config.projects[name] = str(project_path)
self.save_config(self.config)
return ProjectConfig(name=name, home=project_path)
def remove_project(self, name: str) -> None:
"""Remove a project from the configuration."""
@@ -224,15 +228,36 @@ class ConfigManager:
self.save_config(self.config)
def get_project_config() -> ProjectConfig:
"""Get the project configuration for the current session."""
def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
"""
Get the project configuration for the current session.
If project_name is provided, it will be used instead of the default project.
"""
# Get project name from environment variable or use provided name or default
env_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
actual_project_name = env_project_name or config_manager.default_project
actual_project_name = None
# load the config from file
global app_config
app_config = config_manager.load_config()
# Get project name from environment variable
os_project_name = os.environ.get("BASIC_MEMORY_PROJECT", None)
if os_project_name: # pragma: no cover
logger.warning(
f"BASIC_MEMORY_PROJECT is not supported anymore. Use the --project flag or set the default project in the config instead. Setting default project to {os_project_name}"
)
actual_project_name = project_name
# if the project_name is passed in, use it
elif not project_name:
# use default
actual_project_name = app_config.default_project
else: # pragma: no cover
actual_project_name = project_name
# the config contains a dict[str,str] of project names and absolute paths
project_path = config_manager.projects.get(actual_project_name)
assert actual_project_name is not None, "actual_project_name cannot be None"
project_path = app_config.projects.get(actual_project_name)
if not project_path: # pragma: no cover
raise ValueError(f"Project '{actual_project_name}' not found")
@@ -249,6 +274,15 @@ app_config: BasicMemoryConfig = config_manager.config
config: ProjectConfig = get_project_config()
def update_current_project(project_name: str) -> None:
"""Update the global config to use a different project.
This is used by the CLI when --project flag is specified.
"""
global config
config = get_project_config(project_name) # pragma: no cover
# setup logging to a single log file in user home directory
user_home = Path.home()
log_dir = user_home / DATA_DIR_NAME
+33 -4
View File
@@ -1,6 +1,7 @@
"""Dependency injection functions for basic-memory services."""
from typing import Annotated
from loguru import logger
from fastapi import Depends, HTTPException, Path, status
from sqlalchemy.ext.asyncio import (
@@ -8,9 +9,10 @@ from sqlalchemy.ext.asyncio import (
AsyncEngine,
async_sessionmaker,
)
import pathlib
from basic_memory import db
from basic_memory.config import ProjectConfig, config, BasicMemoryConfig
from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.importers import (
ChatGPTImporter,
ClaudeConversationsImporter,
@@ -44,8 +46,30 @@ AppConfigDep = Annotated[BasicMemoryConfig, Depends(get_app_config)] # pragma:
## project
def get_project_config() -> ProjectConfig: # pragma: no cover
return config
async def get_project_config(
project: "ProjectPathDep", project_repository: "ProjectRepositoryDep"
) -> ProjectConfig: # pragma: no cover
"""Get the current project referenced from request state.
Args:
request: The current request object
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_permalink(str(project))
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)] # pragma: no cover
@@ -203,7 +227,12 @@ MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_process
async def get_file_service(
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
) -> FileService:
return FileService(project_config.home, markdown_processor)
logger.debug(
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
)
file_service = FileService(project_config.home, markdown_processor)
logger.debug(f"Created FileService for project: {file_service} ")
return file_service
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
+5 -2
View File
@@ -92,7 +92,6 @@ class EntityParser:
async def parse_file(self, path: Path | str) -> EntityMarkdown:
"""Parse markdown file into EntityMarkdown."""
# TODO move to api endpoint to check if absolute path was requested
# Check if the path is already absolute
if (
isinstance(path, Path)
@@ -101,12 +100,16 @@ class EntityParser:
):
absolute_path = Path(path)
else:
absolute_path = self.base_path / path
absolute_path = self.get_file_path(path)
# Parse frontmatter and content using python-frontmatter
file_content = absolute_path.read_text(encoding="utf-8")
return await self.parse_file_content(absolute_path, file_content)
def get_file_path(self, path):
"""Get absolute path for a file using the base path for the project."""
return self.base_path / path
async def parse_file_content(self, absolute_path, file_content):
post = frontmatter.loads(file_content)
# Extract file stat info
+103
View File
@@ -0,0 +1,103 @@
"""Project session management for Basic Memory MCP server.
Provides simple in-memory project context for MCP tools, allowing users to switch
between projects during a conversation without restarting the server.
"""
from dataclasses import dataclass
from typing import Optional
from loguru import logger
from basic_memory.config import ProjectConfig, get_project_config
@dataclass
class ProjectSession:
"""Simple in-memory project context for MCP session.
This class manages the current project context that tools use when no explicit
project is specified. It's initialized with the default project from config
and can be changed during the conversation.
"""
current_project: Optional[str] = None
default_project: Optional[str] = None
def initialize(self, default_project: str) -> None:
"""Set the default project from config on startup.
Args:
default_project: The project name from configuration
"""
self.default_project = default_project
self.current_project = default_project
logger.info(f"Initialized project session with default project: {default_project}")
def get_current_project(self) -> str:
"""Get the currently active project name.
Returns:
The current project name, falling back to default, then 'main'
"""
return self.current_project or self.default_project or "main"
def set_current_project(self, project_name: str) -> None:
"""Set the current project context.
Args:
project_name: The project to switch to
"""
previous = self.current_project
self.current_project = project_name
logger.info(f"Switched project context: {previous} -> {project_name}")
def get_default_project(self) -> str:
"""Get the default project name from startup.
Returns:
The default project name, or 'main' if not set
"""
return self.default_project or "main" # pragma: no cover
def reset_to_default(self) -> None: # pragma: no cover
"""Reset current project back to the default project."""
self.current_project = self.default_project # pragma: no cover
logger.info(f"Reset project context to default: {self.default_project}") # pragma: no cover
# Global session instance
session = ProjectSession()
def get_active_project(project_override: Optional[str] = None) -> ProjectConfig:
"""Get the active project name for a tool call.
This is the main function tools should use to determine which project
to operate on.
Args:
project_override: Optional explicit project name from tool parameter
Returns:
The project name to use (override takes precedence over session context)
"""
if project_override: # pragma: no cover
project = get_project_config(project_override)
session.set_current_project(project_override)
return project
current_project = session.get_current_project()
return get_project_config(current_project)
def add_project_metadata(result: str, project_name: str) -> str:
"""Add project context as metadata footer for LLM awareness.
Args:
result: The tool result string
project_name: The project name that was used
Returns:
Result with project metadata footer
"""
return f"{result}\n\n<!-- Project: {project_name} -->" # pragma: no cover
@@ -2,14 +2,15 @@
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas import ProjectInfoResponse
@mcp.tool(
@mcp.resource(
uri="memory://project_info",
description="Get information and statistics about the current Basic Memory project.",
)
async def project_info() -> ProjectInfoResponse:
@@ -44,7 +45,8 @@ async def project_info() -> ProjectInfoResponse:
print(f"Basic Memory version: {info.system.version}")
"""
logger.info("Getting project info")
project_url = get_project_config().project_url
project_config = get_active_project()
project_url = project_config.project_url
# Call the API endpoint
response = await call_get(client, f"{project_url}/project/info")
+5
View File
@@ -15,6 +15,7 @@ from mcp.server.auth.settings import AuthSettings
from basic_memory.config import app_config
from basic_memory.services.initialization import initialize_app
from basic_memory.mcp.auth_provider import BasicMemoryOAuthProvider
from basic_memory.mcp.project_session import session
from basic_memory.mcp.external_auth_provider import (
create_github_provider,
create_google_provider,
@@ -37,6 +38,10 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma:
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
watch_task = await initialize_app(app_config)
# Initialize project session with default project
session.initialize(app_config.default_project)
try:
yield AppContext(watch_task=watch_task)
finally:
+20
View File
@@ -14,14 +14,34 @@ from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.project_management import (
list_projects,
switch_project,
get_current_project,
set_default_project,
create_project,
delete_project,
)
__all__ = [
"build_context",
"canvas",
"create_project",
"delete_note",
"delete_project",
"edit_note",
"get_current_project",
"list_directory",
"list_projects",
"move_note",
"read_content",
"read_note",
"recent_activity",
"search_notes",
"set_default_project",
"switch_project",
"write_note",
]
+8 -2
View File
@@ -4,10 +4,10 @@ from typing import Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -36,6 +36,7 @@ async def build_context(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
project: Optional[str] = None,
) -> GraphContext:
"""Get context needed to continue a discussion.
@@ -50,6 +51,7 @@ async def build_context(
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
project: Optional project name to build context from. If not provided, uses current active project.
Returns:
GraphContext containing:
@@ -69,11 +71,15 @@ async def build_context(
# Research the history of a feature
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
# Build context from specific project
build_context("memory://specs/search", project="work-project")
"""
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_get(
client,
+13 -3
View File
@@ -4,14 +4,14 @@ This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec
"""
import json
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.project_session import get_active_project
@mcp.tool(
@@ -22,6 +22,7 @@ async def canvas(
edges: List[Dict[str, Any]],
title: str,
folder: str,
project: Optional[str] = None,
) -> str:
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -35,6 +36,7 @@ async def canvas(
edges: List of edge objects following JSON Canvas 1.0 spec
title: The title of the canvas (will be saved as title.canvas)
folder: The folder where the file should be saved
project: Optional project name to create canvas in. If not provided, uses current active project.
Returns:
A summary of the created canvas file
@@ -72,8 +74,16 @@ async def canvas(
]
}
```
Examples:
# Create canvas in current project
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
# Create canvas in specific project
canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
+10 -5
View File
@@ -1,18 +1,19 @@
from basic_memory.config import get_project_config
from typing import Optional
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import DeleteEntitiesResponse
@mcp.tool(description="Delete a note by title or permalink")
async def delete_note(identifier: str) -> bool:
async def delete_note(identifier: str, project: Optional[str] = None) -> bool:
"""Delete a note from the knowledge base.
Args:
identifier: Note title or permalink
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
True if note was deleted, False otherwise
@@ -23,8 +24,12 @@ async def delete_note(identifier: str) -> bool:
# Delete by permalink
delete_note("notes/project-planning")
# Delete from specific project
delete_note("notes/project-planning", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
+297
View File
@@ -0,0 +1,297 @@
"""Edit note tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_patch
from basic_memory.schemas import EntityResponse
def _format_error_response(
error_message: str,
operation: str,
identifier: str,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> str:
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
# Entity not found errors
if "Entity not found" in error_message or "entity not found" in error_message.lower():
return f"""# Edit Failed - Note Not Found
The note with identifier '{identifier}' could not be found.
## Suggestions to try:
1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes
2. **Try different identifier formats**:
- If you used a permalink like "folder/note-title", try just the title: "{identifier.split("/")[-1].replace("-", " ").title()}"
- If you used a title, try the permalink format: "{identifier.lower().replace(" ", "-")}"
- Use `read_note()` first to verify the note exists and get the correct identifiers
## Alternative approach:
Use `write_note()` to create the note first, then edit it."""
# Find/replace specific errors
if operation == "find_replace":
if "Text to replace not found" in error_message:
return f"""# Edit Failed - Text Not Found
The text '{find_text}' was not found in the note '{identifier}'.
## Suggestions to try:
1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
2. **Check for exact matches**: The search is case-sensitive and must match exactly
3. **Try a broader search**: Search for just part of the text you want to replace
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
## Alternative approaches:
- Use `append` or `prepend` to add new content instead
- Use `replace_section` if you're trying to update a specific section"""
if "Expected" in error_message and "occurrences" in error_message:
# Extract the actual count from error message if possible
import re
match = re.search(r"found (\d+)", error_message)
actual_count = match.group(1) if match else "a different number of"
return f"""# Edit Failed - Wrong Replacement Count
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
## How to fix:
1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
## Example:
```
edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
```"""
# Section replacement errors
if operation == "replace_section" and "Multiple sections" in error_message:
return f"""# Edit Failed - Duplicate Section Headers
Multiple sections found with the same header in note '{identifier}'.
## How to fix:
1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
2. **Make headers unique**: Add more specific text to distinguish sections
3. **Use append instead**: Add content at the end rather than replacing a specific section
## Alternative approach:
Use `find_replace` to update specific text within the duplicate sections."""
# Generic server/request errors
if (
"Invalid request" in error_message or "malformed" in error_message.lower()
): # pragma: no cover
return f"""# Edit Failed - Request Error
There was a problem with the edit request to note '{identifier}': {error_message}.
## Common causes and fixes:
1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
3. **Empty or invalid content**: Check that your content is properly formatted
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
## Troubleshooting steps:
1. Verify the note exists: `read_note("{identifier}")`
2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
3. Try again with the correct identifier from the search results"""
# Fallback for other errors
return f"""# Edit Failed
Error editing note '{identifier}': {error_message}
## General troubleshooting:
1. **Verify the note exists**: Use `read_note("{identifier}")` to check
2. **Check your parameters**: Ensure all required parameters are provided correctly
3. **Read the note content first**: Use `read_note()` to understand the current structure
4. **Try a simpler operation**: Start with `append` if other operations fail
## Need help?
- Use `search_notes()` to find notes
- Use `read_note()` to examine content before editing
- Check that identifiers, section headers, and find_text match exactly"""
@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
)
async def edit_note(
identifier: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
project: Optional[str] = None,
) -> str:
"""Edit an existing markdown note in the knowledge base.
This tool allows you to make targeted changes to existing notes without rewriting the entire content.
It supports various operations for different editing scenarios.
Args:
identifier: The title, permalink, or memory:// URL of the note to edit
operation: The editing operation to perform:
- "append": Add content to the end of the note
- "prepend": Add content to the beginning of the note
- "find_replace": Replace occurrences of find_text with content
- "replace_section": Replace content under a specific markdown header
content: The content to add or use for replacement
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
find_text: For find_replace operation - the text to find and replace
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
A markdown formatted summary of the edit operation and resulting semantic content
Examples:
# Add new content to end of note
edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
# Add timestamp at beginning (frontmatter-aware)
edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
# Update version number (single occurrence)
edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
# Update version in multiple places with validation
edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
# Replace text that appears multiple times - validate count first
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
# Replace implementation section
edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
# Replace subsection with more specific header
edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
# Using different identifier formats
edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # title
edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # permalink
edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # folder/title
# Add new section to document
edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
# Update status across document (expecting exactly 2 occurrences)
edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
# Replace text in a file, specifying project name
edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
"""
active_project = get_active_project(project)
project_url = active_project.project_url
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
# Validate operation
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
if operation not in valid_operations:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
)
# Validate required parameters for specific operations
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
# Use the PATCH endpoint to edit the entity
try:
# Prepare the edit request data
edit_data = {
"operation": operation,
"content": content,
}
# Add optional parameters
if section:
edit_data["section"] = section
if find_text:
edit_data["find_text"] = find_text
if expected_replacements != 1: # Only send if different from default
edit_data["expected_replacements"] = str(expected_replacements)
# Call the PATCH endpoint
url = f"{project_url}/knowledge/entities/{identifier}"
response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json())
# Format summary
summary = [
f"# Edited note ({operation})",
f"project: {active_project.name}",
f"file_path: {result.file_path}",
f"permalink: {result.permalink}",
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
]
# Add operation-specific details
if operation == "append":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to end of note")
elif operation == "prepend":
lines_added = len(content.split("\n"))
summary.append(f"operation: Added {lines_added} lines to beginning of note")
elif operation == "find_replace":
# For find_replace, we can't easily count replacements from here
# since we don't have the original content, but the server handled it
summary.append("operation: Find and replace operation completed")
elif operation == "replace_section":
summary.append(f"operation: Replaced content under section '{section}'")
# Count observations by category (reuse logic from write_note)
categories = {}
if result.observations:
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
# Count resolved/unresolved relations
unresolved = 0
resolved = 0
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
logger.info(
"MCP tool response",
tool="edit_note",
operation=operation,
permalink=result.permalink,
observations_count=len(result.observations),
relations_count=len(result.relations),
status_code=response.status_code,
)
return "\n".join(summary)
except Exception as e:
logger.error(f"Error editing note: {e}")
return _format_error_response(
str(e), operation, identifier, find_text, expected_replacements
)
@@ -0,0 +1,154 @@
"""List directory tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
@mcp.tool(
description="List directory contents with filtering and depth control.",
)
async def list_directory(
dir_name: str = "/",
depth: int = 1,
file_name_glob: Optional[str] = None,
project: Optional[str] = None,
) -> str:
"""List directory contents from the knowledge base with optional filtering.
This tool provides 'ls' functionality for browsing the knowledge base directory structure.
It can list immediate children or recursively explore subdirectories with depth control,
and supports glob pattern filtering for finding specific files.
Args:
dir_name: Directory path to list (default: root "/")
Examples: "/", "/projects", "/research/ml"
depth: Recursion depth (1-10, default: 1 for immediate children only)
Higher values show subdirectory contents recursively
file_name_glob: Optional glob pattern for filtering file names
Examples: "*.md", "*meeting*", "project_*"
project: Optional project name to delete from. If not provided, uses current active project.
Returns:
Formatted listing of directory contents with file metadata
Examples:
# List root directory contents
list_directory()
# List specific folder
list_directory(dir_name="/projects")
# Find all Python files
list_directory(file_name_glob="*.py")
# Deep exploration of research folder
list_directory(dir_name="/research", depth=3)
# Find meeting notes in projects folder
list_directory(dir_name="/projects", file_name_glob="*meeting*")
# Find meeting notes in a specific project
list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
"""
active_project = get_active_project(project)
project_url = active_project.project_url
# 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}' with depth={depth}, glob='{file_name_glob}'")
# Call the API endpoint
response = await call_get(
client,
f"{project_url}/directory/list",
params=params,
)
nodes = response.json()
if not nodes:
filter_desc = ""
if file_name_glob:
filter_desc = f" matching '{file_name_glob}'"
return f"No files found in directory '{dir_name}'{filter_desc}"
# Format the results
output_lines = []
if file_name_glob:
output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
else:
output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
output_lines.append("")
# Group by type and sort
directories = [n for n in nodes if n["type"] == "directory"]
files = [n for n in nodes if n["type"] == "file"]
# Sort by name
directories.sort(key=lambda x: x["name"])
files.sort(key=lambda x: x["name"])
# Display directories first
for node in directories:
path_display = node["directory_path"]
output_lines.append(f"📁 {node['name']:<30} {path_display}")
# Add separator if we have both directories and files
if directories and files:
output_lines.append("")
# Display files with metadata
for node in files:
path_display = node["directory_path"]
title = node.get("title", "")
updated = node.get("updated_at", "")
# Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
if path_display.startswith("/"):
path_display = path_display[1:]
# Format date if available
date_str = ""
if updated:
try:
from datetime import datetime
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
except Exception: # pragma: no cover
date_str = updated[:10] if len(updated) >= 10 else ""
# Create formatted line
file_line = f"📄 {node['name']:<30} {path_display}"
if title and title != node["name"]:
file_line += f" | {title}"
if date_str:
file_line += f" | {date_str}"
output_lines.append(file_line)
# Add summary
output_lines.append("")
total_count = len(directories) + len(files)
summary_parts = []
if directories:
summary_parts.append(
f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
)
if files:
summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
return "\n".join(output_lines)
+87
View File
@@ -0,0 +1,87 @@
"""Move note tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import EntityResponse
@mcp.tool(
description="Move a note to a new location, updating database and maintaining links.",
)
async def move_note(
identifier: str,
destination_path: str,
project: Optional[str] = None,
) -> str:
"""Move a note to a new file location within the same project.
Args:
identifier: Entity identifier (title, permalink, or memory:// URL)
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
project: Optional project name (defaults to current session project)
Returns:
Success message with move details
Examples:
- Move to new folder: move_note("My Note", "work/notes/my-note.md")
- Move by permalink: move_note("my-note-permalink", "archive/old-notes/my-note.md")
- Specify project: move_note("My Note", "archive/my-note.md", project="work-project")
Note: This operation moves notes within the specified project only. Moving notes
between different projects is not currently supported.
The move operation:
- Updates the entity's file_path in the database
- Moves the physical file on the filesystem
- Optionally updates permalinks if configured
- Re-indexes the entity for search
- Maintains all observations and relations
"""
logger.debug(f"Moving note: {identifier} to {destination_path}")
active_project = get_active_project(project)
project_url = active_project.project_url
# Prepare move request
move_data = {
"identifier": identifier,
"destination_path": destination_path,
"project": active_project.name,
}
# Call the move API endpoint
url = f"{project_url}/knowledge/move"
response = await call_post(client, url, json=move_data)
result = EntityResponse.model_validate(response.json())
# 10. Build success message
result_lines = [
"✅ Note moved successfully",
"",
f"📁 **{identifier}** → **{result.file_path}**",
f"🔗 Permalink: {result.permalink}",
"📊 Database and search index updated",
"",
f"<!-- Project: {active_project.name} -->",
]
# Return the response text which contains the formatted success message
result = "\n".join(result_lines)
# Log the operation
logger.info(
"Move note completed",
identifier=identifier,
destination_path=destination_path,
project=active_project.name,
status_code=response.status_code,
)
return result
@@ -0,0 +1,300 @@
"""Project management tools for Basic Memory MCP server.
These tools allow users to switch between projects, list available projects,
and manage project context during conversations.
"""
from fastmcp import Context
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.project_session import session, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
@mcp.tool()
async def list_projects(ctx: Context | None = None) -> str:
"""List all available projects with their status.
Shows all Basic Memory projects that are available, indicating which one
is currently active and which is the default.
Returns:
Formatted list of projects with status indicators
Example:
list_projects()
"""
if ctx: # pragma: no cover
await ctx.info("Listing all available projects")
# Get projects from API
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
current = session.get_current_project()
result = "Available projects:\n"
for project in project_list.projects:
indicators = []
if project.name == current:
indicators.append("current")
if project.is_default:
indicators.append("default")
if indicators:
result += f"{project.name} ({', '.join(indicators)})\n"
else:
result += f"{project.name}\n"
return add_project_metadata(result, current)
@mcp.tool()
async def switch_project(project_name: str, ctx: Context | None = None) -> str:
"""Switch to a different project context.
Changes the active project context for all subsequent tool calls.
Shows a project summary after switching successfully.
Args:
project_name: Name of the project to switch to
Returns:
Confirmation message with project summary
Example:
switch_project("work-notes")
switch_project("personal-journal")
"""
if ctx: # pragma: no cover
await ctx.info(f"Switching to project: {project_name}")
current_project = session.get_current_project()
try:
# Validate project exists by getting project list
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Check if project exists
project_exists = any(p.name == project_name for p in project_list.projects)
if not project_exists:
available_projects = [p.name for p in project_list.projects]
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
# Switch to the project
session.set_current_project(project_name)
current_project = session.get_current_project()
project_config = get_project_config(current_project)
# Get project info to show summary
try:
response = await call_get(client, f"{project_config.project_url}/project/info")
project_info = ProjectInfoResponse.model_validate(response.json())
result = f"✓ Switched to {project_name} project\n\n"
result += "Project Summary:\n"
result += f"{project_info.statistics.total_entities} entities\n"
result += f"{project_info.statistics.total_observations} observations\n"
result += f"{project_info.statistics.total_relations} relations\n"
except Exception as e:
# If we can't get project info, still confirm the switch
logger.warning(f"Could not get project info for {project_name}: {e}")
result = f"✓ Switched to {project_name} project\n\n"
result += "Project summary unavailable.\n"
return add_project_metadata(result, project_name)
except Exception as e:
logger.error(f"Error switching to project {project_name}: {e}")
# Revert to previous project on error
session.set_current_project(current_project)
raise e
@mcp.tool()
async def get_current_project(ctx: Context | None = None) -> str:
"""Show the currently active project and basic stats.
Displays which project is currently active and provides basic information
about it.
Returns:
Current project name and basic statistics
Example:
get_current_project()
"""
if ctx: # pragma: no cover
await ctx.info("Getting current project information")
current_project = session.get_current_project()
project_config = get_project_config(current_project)
result = f"Current project: {current_project}\n\n"
# get project stats
response = await call_get(client, f"{project_config.project_url}/project/info")
project_info = ProjectInfoResponse.model_validate(response.json())
result += f"{project_info.statistics.total_entities} entities\n"
result += f"{project_info.statistics.total_observations} observations\n"
result += f"{project_info.statistics.total_relations} relations\n"
default_project = session.get_default_project()
if current_project != default_project:
result += f"• Default project: {default_project}\n"
return add_project_metadata(result, current_project)
@mcp.tool()
async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
"""Set default project in config. Requires restart to take effect.
Updates the configuration to use a different default project. This change
only takes effect after restarting the Basic Memory server.
Args:
project_name: Name of the project to set as default
Returns:
Confirmation message about config update
Example:
set_default_project("work-notes")
"""
if ctx: # pragma: no cover
await ctx.info(f"Setting default project to: {project_name}")
# Call API to set default project
response = await call_put(client, f"/projects/{project_name}/default")
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
result += "Restart Basic Memory for this change to take effect:\n"
result += "basic-memory mcp\n"
if status_response.old_project:
result += f"\nPrevious default: {status_response.old_project.name}\n"
return add_project_metadata(result, session.get_current_project())
@mcp.tool()
async def create_project(
project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
) -> str:
"""Create a new Basic Memory project.
Creates a new project with the specified name and path. The project directory
will be created if it doesn't exist. Optionally sets the new project as default.
Args:
project_name: Name for the new project (must be unique)
project_path: File system path where the project will be stored
set_default: Whether to set this project as the default (optional, defaults to False)
Returns:
Confirmation message with project details
Example:
create_project("my-research", "~/Documents/research")
create_project("work-notes", "/home/user/work", set_default=True)
"""
if ctx: # pragma: no cover
await ctx.info(f"Creating project: {project_name} at {project_path}")
# Create the project request
project_request = ProjectInfoRequest(
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())
result = f"{status_response.message}\n\n"
if status_response.new_project:
result += "Project Details:\n"
result += f"• Name: {status_response.new_project.name}\n"
result += f"• Path: {status_response.new_project.path}\n"
if set_default:
result += "• Set as default project\n"
result += "\nProject is now available for use.\n"
# If project was set as default, update session
if set_default:
session.set_current_project(project_name)
return add_project_metadata(result, session.get_current_project())
@mcp.tool()
async def delete_project(project_name: str, ctx: Context | None = None) -> str:
"""Delete a Basic Memory project.
Removes a project from the configuration and database. This does NOT delete
the actual files on disk - only removes the project from Basic Memory's
configuration and database records.
Args:
project_name: Name of the project to delete
Returns:
Confirmation message about project deletion
Example:
delete_project("old-project")
Warning:
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
if ctx: # pragma: no cover
await ctx.info(f"Deleting project: {project_name}")
current_project = session.get_current_project()
# Check if trying to delete current project
if project_name == current_project:
raise ValueError(
f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
)
# Get project info before deletion to validate it exists
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Check if project exists
project_exists = any(p.name == project_name for p in project_list.projects)
if not project_exists:
available_projects = [p.name for p in project_list.projects]
raise ValueError(
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
)
# Call API to delete project
response = await call_delete(client, f"/projects/{project_name}")
status_response = ProjectStatusResponse.model_validate(response.json())
result = f"{status_response.message}\n\n"
if status_response.old_project:
result += "Removed project details:\n"
result += f"• Name: {status_response.old_project.name}\n"
if hasattr(status_response.old_project, "path"):
result += f"• Path: {status_response.old_project.path}\n"
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
result += "Re-add the project to access its content again.\n"
return add_project_metadata(result, session.get_current_project())
+14 -8
View File
@@ -5,18 +5,19 @@ supporting various file types including text, images, and other binary files.
Files are read directly without any knowledge graph processing.
"""
from loguru import logger
from typing import Optional
import base64
import io
from loguru import logger
from PIL import Image as PILImage
from basic_memory.config import get_project_config
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.memory import memory_url_path
import base64
import io
from PIL import Image as PILImage
def calculate_target_params(content_length):
"""Calculate initial quality and size based on input file size"""
@@ -145,7 +146,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
@mcp.tool(description="Read a file's raw content by path or permalink")
async def read_content(path: str) -> dict:
async def read_content(path: str, project: Optional[str] = None) -> dict:
"""Read a file's raw content by path or permalink.
This tool provides direct access to file content in the knowledge base,
@@ -159,6 +160,7 @@ async def read_content(path: str) -> dict:
- A regular file path (docs/example.md)
- A memory URL (memory://docs/example)
- A permalink (docs/example)
project: Optional project name to read from. If not provided, uses current active project.
Returns:
A dictionary with the file content and metadata:
@@ -176,10 +178,14 @@ async def read_content(path: str) -> dict:
# Read using memory URL
content = await read_file("memory://docs/architecture")
# Read from specific project
content = await read_content("docs/example.md", project="work-project")
"""
logger.info("Reading file", path=path)
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
url = memory_url_path(path)
response = await call_get(client, f"{project_url}/resource/{url}")
+13 -5
View File
@@ -1,21 +1,24 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
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
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
description="Read a markdown note by title or permalink.",
)
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
async def read_note(
identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
) -> str:
"""Read a markdown note from the knowledge base.
This tool finds and retrieves a note by its title, permalink, or content search,
@@ -27,6 +30,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
Can be a full memory:// URL, a permalink, a title, or search text
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
project: Optional project name to read from. If not provided, uses current active project.
Returns:
The full markdown content of the note if found, or helpful guidance if not found.
@@ -43,9 +47,13 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Read with pagination
read_note("Project Updates", page=2, page_size=5)
# Read from specific project
read_note("Meeting Notes", project="work-project")
"""
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
# Get the file via REST API - first try direct permalink lookup
entity_path = memory_url_path(identifier)
@@ -66,7 +74,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes(query=identifier, search_type="title")
title_results = await search_notes(query=identifier, search_type="title", project=project)
if title_results and title_results.results:
result = title_results.results[0] # Get the first/best match
@@ -90,7 +98,7 @@ async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes(query=identifier, search_type="text")
text_results = await search_notes(query=identifier, search_type="text", project=project)
# We didn't find a direct match, construct a helpful error message
if not text_results or not text_results.results:
@@ -1,13 +1,13 @@
"""Recent activity tool for Basic Memory MCP server."""
from typing import List, Union
from typing import List, Union, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import GraphContext
from basic_memory.schemas.search import SearchItemType
@@ -32,6 +32,7 @@ async def recent_activity(
page: int = 1,
page_size: int = 10,
max_related: int = 10,
project: Optional[str] = None,
) -> GraphContext:
"""Get recent activity across the knowledge base.
@@ -52,6 +53,7 @@ async def recent_activity(
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
project: Optional project name to get activity from. If not provided, uses current active project.
Returns:
GraphContext containing:
@@ -75,6 +77,9 @@ async def recent_activity(
# Look back further with more context
recent_activity(type="entity", depth=2, timeframe="2 weeks ago")
# Get activity from specific project
recent_activity(type="entity", project="work-project")
Notes:
- Higher depth values (>3) may impact performance with large result sets
- For focused queries, consider using build_context with a specific URI
@@ -115,7 +120,8 @@ async def recent_activity(
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
response = await call_get(
client,
+8 -2
View File
@@ -4,10 +4,10 @@ from typing import List, Optional
from loguru import logger
from basic_memory.config import get_project_config
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_post
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -22,6 +22,7 @@ async def search_notes(
types: Optional[List[str]] = None,
entity_types: Optional[List[str]] = None,
after_date: Optional[str] = None,
project: Optional[str] = None,
) -> SearchResponse:
"""Search across all content in the knowledge base.
@@ -37,6 +38,7 @@ async def search_notes(
types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d")
project: Optional project name to search in. If not provided, uses current active project.
Returns:
SearchResponse with results and pagination info
@@ -80,6 +82,9 @@ async def search_notes(
query="docs/meeting-*",
search_type="permalink"
)
# Search in specific project
results = await search_notes("meeting notes", project="work-project")
"""
# Create a SearchQuery object based on the parameters
search_query = SearchQuery()
@@ -104,7 +109,8 @@ async def search_notes(
if after_date:
search_query.after_date = after_date
project_url = get_project_config().project_url
active_project = get_active_project(project)
project_url = active_project.project_url
logger.info(f"Searching for {search_query}")
response = await call_post(
+136 -12
View File
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
"""
import typing
from typing import Optional
from httpx import Response, URL, AsyncClient, HTTPStatusError
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -23,7 +24,9 @@ from loguru import logger
from mcp.server.fastmcp.exceptions import ToolError
def get_error_message(status_code: int, url: URL | str, method: str) -> str:
def get_error_message(
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
) -> str:
"""Get a friendly error message based on the HTTP status code.
Args:
@@ -103,6 +106,7 @@ async def call_get(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling GET '{url}' params: '{params}'")
error_message = None
try:
response = await client.get(
url,
@@ -120,7 +124,12 @@ async def call_get(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "GET")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PUT")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -138,8 +147,6 @@ async def call_get(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "GET")
raise ToolError(error_message) from e
@@ -183,6 +190,8 @@ async def call_put(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling PUT '{url}'")
error_message = None
try:
response = await client.put(
url,
@@ -204,7 +213,13 @@ async def call_put(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "PUT")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"] # pragma: no cover
else:
error_message = get_error_message(status_code, url, "PUT")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -221,9 +236,110 @@ async def call_put(
response.raise_for_status() # Will always raise since we're in the error case
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
raise ToolError(error_message) from e
async def call_patch(
client: AsyncClient,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
"""Make a PATCH request and handle errors appropriately.
Args:
client: The HTTPX AsyncClient to use
url: The URL to request
content: Request content
data: Form data
files: Files to upload
json: JSON data
params: Query parameters
headers: HTTP headers
cookies: HTTP cookies
auth: Authentication
follow_redirects: Whether to follow redirects
timeout: Request timeout
extensions: HTTPX extensions
Returns:
The HTTP response
Raises:
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling PATCH '{url}'")
try:
response = await client.patch(
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)
if response.is_success:
return response
# Handle different status codes differently
status_code = response.status_code
# Try to extract specific error message from response body
try:
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
except Exception: # pragma: no cover
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
# Log at appropriate level based on status code
if 400 <= status_code < 500:
# Client errors: log as info except for 429 (Too Many Requests)
if status_code == 429: # pragma: no cover
logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
else:
logger.info(f"Client error: PATCH {url}: {error_message}")
else: # pragma: no cover
# Server errors: log as error
logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
# Raise a tool error with the friendly message
response.raise_for_status() # Will always raise since we're in the error case
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "PUT")
# Try to extract specific error message from response body
try:
response_data = e.response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
except Exception: # pragma: no cover
error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
raise ToolError(error_message) from e
@@ -267,6 +383,7 @@ async def call_post(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling POST '{url}'")
error_message = None
try:
response = await client.post(
url=url,
@@ -289,7 +406,12 @@ async def call_post(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "POST")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"]
else:
error_message = get_error_message(status_code, url, "POST")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -307,8 +429,6 @@ async def call_post(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "POST")
raise ToolError(error_message) from e
@@ -344,6 +464,7 @@ async def call_delete(
ToolError: If the request fails with an appropriate error message
"""
logger.debug(f"Calling DELETE '{url}'")
error_message = None
try:
response = await client.delete(
url=url,
@@ -361,7 +482,12 @@ async def call_delete(
# Handle different status codes differently
status_code = response.status_code
error_message = get_error_message(status_code, url, "DELETE")
# get the message if available
response_data = response.json()
if isinstance(response_data, dict) and "detail" in response_data:
error_message = response_data["detail"] # pragma: no cover
else:
error_message = get_error_message(status_code, url, "DELETE")
# Log at appropriate level based on status code
if 400 <= status_code < 500:
@@ -379,6 +505,4 @@ async def call_delete(
return response # This line will never execute, but it satisfies the type checker # pragma: no cover
except HTTPStatusError as e:
status_code = e.response.status_code
error_message = get_error_message(status_code, url, "DELETE")
raise ToolError(error_message) from e
+10 -17
View File
@@ -1,16 +1,16 @@
"""Write note tool for Basic Memory MCP server."""
from typing import List, Union
from typing import List, Union, Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.project_session import get_active_project
from basic_memory.schemas import EntityResponse
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags
from basic_memory.config import get_project_config
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@@ -27,6 +27,7 @@ async def write_note(
content: str,
folder: str,
tags=None, # Remove type hint completely to avoid schema issues
project: Optional[str] = None,
) -> str:
"""Write a markdown note to the knowledge base.
@@ -56,6 +57,7 @@ async def write_note(
folder: the folder where the file should be saved
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
project: Optional project name to write to. If not provided, uses current active project.
Returns:
A markdown formatted summary of the semantic content, including:
@@ -65,12 +67,12 @@ async def write_note(
- Relation counts (resolved/unresolved)
- Tags if present
"""
logger.info("MCP tool call", tool="write_note", folder=folder, title=title, tags=tags)
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
# Process tags using the helper function
tag_list = parse_tags(tags)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
metadata = {"tags": tag_list} if tag_list else None
entity = Entity(
title=title,
folder=folder,
@@ -79,11 +81,11 @@ async def write_note(
content=content,
entity_metadata=metadata,
)
project_url = get_project_config().project_url
print(f"project_url: {project_url}")
active_project = get_active_project(project)
project_url = active_project.project_url
# Create or update via knowledge API
logger.debug("Creating entity via API", permalink=entity.permalink)
logger.debug(f"Creating entity via API permalink={entity.permalink}")
url = f"{project_url}/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
@@ -125,15 +127,6 @@ async def write_note(
# Log the response with structured data
logger.info(
"MCP tool response",
tool="write_note",
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 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}"
)
return "\n".join(summary)
@@ -130,26 +130,35 @@ class SearchRepository:
For FTS5:
- Special characters and phrases need to be quoted
- Terms with spaces or special chars need quotes
- Boolean operators (AND, OR, NOT) and parentheses are preserved
- Boolean operators (AND, OR, NOT) are preserved for complex queries
"""
if "*" in term:
return term
# Check for boolean operators - if present, return the term as is
boolean_operators = [" AND ", " OR ", " NOT ", "(", ")"]
# Check for explicit boolean operators - if present, return the term as is
boolean_operators = [" AND ", " OR ", " NOT "]
if any(op in f" {term} " for op in boolean_operators):
return term
# List of special characters that need quoting (excluding *)
# List of FTS5 special characters that need escaping/quoting
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
# Check if term contains any special characters
needs_quotes = any(c in term for c in special_chars)
if needs_quotes:
# If the term already contains quotes, escape them and add a wildcard
term = term.replace('"', '""')
term = f'"{term}"*'
# Escape any existing quotes by doubling them
escaped_term = term.replace('"', '""')
# Quote the entire term to handle special characters safely
if is_prefix and not ("/" in term and term.endswith(".md")):
# For search terms (not file paths), add prefix matching
term = f'"{escaped_term}"*'
else:
# For file paths, use exact matching
term = f'"{escaped_term}"'
elif is_prefix:
# Only add wildcard for simple terms without special characters
term = f"{term}*"
return term
@@ -172,9 +181,8 @@ class SearchRepository:
# Handle text search for title and content
if search_text:
has_boolean = any(
op in f" {search_text} " for op in [" AND ", " OR ", " NOT ", "(", ")"]
)
# Check for explicit boolean operators - only detect them in proper boolean contexts
has_boolean = any(op in f" {search_text} " for op in [" AND ", " OR ", " NOT "])
if has_boolean:
# If boolean operators are present, use the raw query
@@ -189,9 +197,9 @@ class SearchRepository:
# Handle title match search
if title:
title_text = self._prepare_search_term(title.strip())
params["text"] = title_text
conditions.append("title MATCH :text")
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
params["title_text"] = title_text
conditions.append("title MATCH :title_text")
# Handle permalink exact search
if permalink:
+16 -18
View File
@@ -107,7 +107,7 @@ class ProjectInfoResponse(BaseModel):
system: SystemStatus = Field(description="System and service status information")
class ProjectSwitchRequest(BaseModel):
class ProjectInfoRequest(BaseModel):
"""Request model for switching projects."""
name: str = Field(..., description="Name of the project to switch to")
@@ -177,27 +177,12 @@ class ProjectWatchStatus(BaseModel):
)
class ProjectStatusResponse(BaseModel):
"""Response model for switching projects."""
message: str = Field(..., description="Status message about the project switch")
status: str = Field(..., description="Status of the switch (success or error)")
default: bool = Field(..., description="True if the project was set as the default")
old_project: Optional[ProjectWatchStatus] = Field(
None, description="Information about the project being switched from"
)
new_project: Optional[ProjectWatchStatus] = Field(
None, description="Information about the project being switched to"
)
class ProjectItem(BaseModel):
"""Simple representation of a project."""
name: str
path: str
is_default: bool
is_current: bool
is_default: bool = False
class ProjectList(BaseModel):
@@ -205,4 +190,17 @@ class ProjectList(BaseModel):
projects: List[ProjectItem]
default_project: str
current_project: str
class ProjectStatusResponse(BaseModel):
"""Response model for switching projects."""
message: str = Field(..., description="Status message about the project switch")
status: str = Field(..., description="Status of the switch (success or error)")
default: bool = Field(..., description="True if the project was set as the default")
old_project: Optional[ProjectItem] = Field(
None, description="Information about the project being switched from"
)
new_project: Optional[ProjectItem] = Field(
None, description="Information about the project being switched to"
)
+56 -2
View File
@@ -1,9 +1,9 @@
"""Request schemas for interacting with the knowledge graph."""
from typing import List, Optional, Annotated
from typing import List, Optional, Annotated, Literal
from annotated_types import MaxLen, MinLen
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from basic_memory.schemas.base import (
Relation,
@@ -56,3 +56,57 @@ class GetEntitiesRequest(BaseModel):
class CreateRelationsRequest(BaseModel):
relations: List[Relation]
class EditEntityRequest(BaseModel):
"""Request schema for editing an existing entity's content.
This allows for targeted edits without requiring the full entity content.
Supports various operation types for different editing scenarios.
"""
operation: Literal["append", "prepend", "find_replace", "replace_section"]
content: str
section: Optional[str] = None
find_text: Optional[str] = None
expected_replacements: int = 1
@field_validator("section")
@classmethod
def validate_section_for_replace_section(cls, v, info):
"""Ensure section is provided for replace_section operation."""
if info.data.get("operation") == "replace_section" and not v:
raise ValueError("section parameter is required for replace_section operation")
return v
@field_validator("find_text")
@classmethod
def validate_find_text_for_find_replace(cls, v, info):
"""Ensure find_text is provided for find_replace operation."""
if info.data.get("operation") == "find_replace" and not v:
raise ValueError("find_text parameter is required for find_replace operation")
return v
class MoveEntityRequest(BaseModel):
"""Request schema for moving an entity to a new file location.
This allows moving notes to different paths while maintaining project
consistency and optionally updating permalinks based on configuration.
"""
identifier: Annotated[str, MinLen(1), MaxLen(200)]
destination_path: Annotated[str, MinLen(1), MaxLen(500)]
project: Optional[str] = None
@field_validator("destination_path")
@classmethod
def validate_destination_path(cls, v):
"""Ensure destination path is relative and valid."""
if v.startswith("/"):
raise ValueError("destination_path must be relative, not absolute")
if ".." in v:
raise ValueError("destination_path cannot contain '..' path components")
if not v.strip():
raise ValueError("destination_path cannot be empty or whitespace only")
return v.strip()
+79 -1
View File
@@ -1,8 +1,9 @@
"""Directory service for managing file directories and tree structure."""
import fnmatch
import logging
import os
from typing import Dict
from typing import Dict, List, Optional
from basic_memory.repository import EntityRepository
from basic_memory.schemas.directory import DirectoryNode
@@ -87,3 +88,80 @@ class DirectoryService:
# Return the root node with its children
return root_node
async def list_directory(
self,
dir_name: str = "/",
depth: int = 1,
file_name_glob: Optional[str] = None,
) -> List[DirectoryNode]:
"""List directory contents with filtering and depth control.
Args:
dir_name: Directory path to list (default: root "/")
depth: Recursion depth (1 = immediate children only)
file_name_glob: Glob pattern for filtering file names
Returns:
List of DirectoryNode objects matching the criteria
"""
# Normalize directory path
if not dir_name.startswith("/"):
dir_name = f"/{dir_name}"
if dir_name != "/" and dir_name.endswith("/"):
dir_name = dir_name.rstrip("/")
# Get the full directory tree
root_tree = await self.get_directory_tree()
# Find the target directory node
target_node = self._find_directory_node(root_tree, dir_name)
if not target_node:
return []
# Collect nodes with depth and glob filtering
result = []
self._collect_nodes_recursive(target_node, result, depth, file_name_glob, 0)
return result
def _find_directory_node(
self, root: DirectoryNode, target_path: str
) -> Optional[DirectoryNode]:
"""Find a directory node by path in the tree."""
if root.directory_path == target_path:
return root
for child in root.children:
if child.type == "directory":
found = self._find_directory_node(child, target_path)
if found:
return found
return None
def _collect_nodes_recursive(
self,
node: DirectoryNode,
result: List[DirectoryNode],
max_depth: int,
file_name_glob: Optional[str],
current_depth: int,
) -> None:
"""Recursively collect nodes with depth and glob filtering."""
if current_depth >= max_depth:
return
for child in node.children:
# Apply glob filtering
if file_name_glob and not fnmatch.fnmatch(child.name, file_name_glob):
continue
# Add the child to results
result.append(child)
# Recurse into subdirectories if we haven't reached max depth
if child.type == "directory" and current_depth < max_depth:
self._collect_nodes_recursive(
child, result, max_depth, file_name_glob, current_depth + 1
)
+377 -2
View File
@@ -4,9 +4,12 @@ from pathlib import Path
from typing import List, Optional, Sequence, Tuple, Union
import frontmatter
import yaml
from loguru import logger
from sqlalchemy.exc import IntegrityError
from basic_memory.config import ProjectConfig, BasicMemoryConfig
from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter
from basic_memory.markdown import EntityMarkdown
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
@@ -114,8 +117,29 @@ class EntityService(BaseService[EntityModel]):
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
)
# Get unique permalink
permalink = await self.resolve_permalink(schema.permalink or file_path)
# Parse content frontmatter to check for user-specified permalink
content_markdown = None
if schema.content and has_frontmatter(schema.content):
content_frontmatter = parse_frontmatter(schema.content)
if "permalink" in content_frontmatter:
# Create a minimal EntityMarkdown object for permalink resolution
from basic_memory.markdown.schemas import EntityFrontmatter
frontmatter_metadata = {
"title": schema.title,
"type": schema.entity_type,
"permalink": content_frontmatter["permalink"],
}
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
content_markdown = EntityMarkdown(
frontmatter=frontmatter_obj,
content="", # content not needed for permalink resolution
observations=[],
relations=[],
)
# Get unique permalink (prioritizing content frontmatter)
permalink = await self.resolve_permalink(file_path, content_markdown)
schema._permalink = permalink
post = await schema_to_markdown(schema)
@@ -148,12 +172,47 @@ class EntityService(BaseService[EntityModel]):
# Read existing frontmatter from the file if it exists
existing_markdown = await self.entity_parser.parse_file(file_path)
# Parse content frontmatter to check for user-specified permalink
content_markdown = None
if schema.content and has_frontmatter(schema.content):
content_frontmatter = parse_frontmatter(schema.content)
if "permalink" in content_frontmatter:
# Create a minimal EntityMarkdown object for permalink resolution
from basic_memory.markdown.schemas import EntityFrontmatter
frontmatter_metadata = {
"title": schema.title,
"type": schema.entity_type,
"permalink": content_frontmatter["permalink"],
}
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
content_markdown = EntityMarkdown(
frontmatter=frontmatter_obj,
content="", # content not needed for permalink resolution
observations=[],
relations=[],
)
# Check if we need to update the permalink based on content frontmatter
new_permalink = entity.permalink # Default to existing
if content_markdown and content_markdown.frontmatter.permalink:
# Resolve permalink with the new content frontmatter
resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
if resolved_permalink != entity.permalink:
new_permalink = resolved_permalink
# Update the schema to use the new permalink
schema._permalink = new_permalink
# Create post with new content from schema
post = await schema_to_markdown(schema)
# Merge new metadata with existing metadata
existing_markdown.frontmatter.metadata.update(post.metadata)
# Ensure the permalink in the metadata is the resolved one
if new_permalink != entity.permalink:
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
# Create a new post with merged metadata
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
@@ -325,3 +384,319 @@ class EntityService(BaseService[EntityModel]):
continue
return await self.repository.get_by_file_path(path)
async def edit_entity(
self,
identifier: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> EntityModel:
"""Edit an existing entity's content using various operations.
Args:
identifier: Entity identifier (permalink, title, etc.)
operation: The editing operation (append, prepend, find_replace, replace_section)
content: The content to add or use for replacement
section: For replace_section operation - the markdown header
find_text: For find_replace operation - the text to find and replace
expected_replacements: For find_replace operation - expected number of replacements (default: 1)
Returns:
The updated entity model
Raises:
EntityNotFoundError: If the entity cannot be found
ValueError: If required parameters are missing for the operation or replacement count doesn't match expected
"""
logger.debug(f"Editing entity: {identifier}, operation: {operation}")
# Find the entity using the link resolver
entity = await self.link_resolver.resolve_link(identifier)
if not entity:
raise EntityNotFoundError(f"Entity not found: {identifier}")
# Read the current file content
file_path = Path(entity.file_path)
current_content, _ = await self.file_service.read_file(file_path)
# Apply the edit operation
new_content = self.apply_edit_operation(
current_content, operation, content, section, find_text, expected_replacements
)
# Write the updated content back to the file
checksum = await self.file_service.write_file(file_path, new_content)
# Parse the updated file to get new observations/relations
entity_markdown = await self.entity_parser.parse_file(file_path)
# Update entity and its relationships
entity = await self.update_entity_and_observations(file_path, entity_markdown)
await self.update_entity_relations(str(file_path), entity_markdown)
# Set final checksum to match file
entity = await self.repository.update(entity.id, {"checksum": checksum})
return entity
def apply_edit_operation(
self,
current_content: str,
operation: str,
content: str,
section: Optional[str] = None,
find_text: Optional[str] = None,
expected_replacements: int = 1,
) -> str:
"""Apply the specified edit operation to the current content."""
if operation == "append":
# Ensure proper spacing
if current_content and not current_content.endswith("\n"):
return current_content + "\n" + content
return current_content + content # pragma: no cover
elif operation == "prepend":
# Handle frontmatter-aware prepending
return self._prepend_after_frontmatter(current_content, content)
elif operation == "find_replace":
if not find_text:
raise ValueError("find_text is required for find_replace operation")
if not find_text.strip():
raise ValueError("find_text cannot be empty or whitespace only")
# Count actual occurrences
actual_count = current_content.count(find_text)
# Validate count matches expected
if actual_count != expected_replacements:
if actual_count == 0:
raise ValueError(f"Text to replace not found: '{find_text}'")
else:
raise ValueError(
f"Expected {expected_replacements} occurrences of '{find_text}', "
f"but found {actual_count}"
)
return current_content.replace(find_text, content)
elif operation == "replace_section":
if not section:
raise ValueError("section is required for replace_section operation")
if not section.strip():
raise ValueError("section cannot be empty or whitespace only")
return self.replace_section_content(current_content, section, content)
else:
raise ValueError(f"Unsupported operation: {operation}")
def replace_section_content(
self, current_content: str, section_header: str, new_content: str
) -> str:
"""Replace content under a specific markdown section header.
This method uses a simple, safe approach: when replacing a section, it only
replaces the immediate content under that header until it encounters the next
header of ANY level. This means:
- Replacing "# Header" replaces content until "## Subsection" (preserves subsections)
- Replacing "## Section" replaces content until "### Subsection" (preserves subsections)
- More predictable and safer than trying to consume entire hierarchies
Args:
current_content: The current markdown content
section_header: The section header to find and replace (e.g., "## Section Name")
new_content: The new content to replace the section with
Returns:
The updated content with the section replaced
Raises:
ValueError: If multiple sections with the same header are found
"""
# Normalize the section header (ensure it starts with #)
if not section_header.startswith("#"):
section_header = "## " + section_header
# First pass: count matching sections to check for duplicates
lines = current_content.split("\n")
matching_sections = []
for i, line in enumerate(lines):
if line.strip() == section_header.strip():
matching_sections.append(i)
# Handle multiple sections error
if len(matching_sections) > 1:
raise ValueError(
f"Multiple sections found with header '{section_header}'. "
f"Section replacement requires unique headers."
)
# If no section found, append it
if len(matching_sections) == 0:
logger.info(f"Section '{section_header}' not found, appending to end of document")
separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
return current_content + separator + section_header + "\n" + new_content
# Replace the single matching section
result_lines = []
section_line_idx = matching_sections[0]
i = 0
while i < len(lines):
line = lines[i]
# Check if this is our target section header
if i == section_line_idx:
# Add the section header and new content
result_lines.append(line)
result_lines.append(new_content)
i += 1
# Skip the original section content until next header or end
while i < len(lines):
next_line = lines[i]
# Stop consuming when we hit any header (preserve subsections)
if next_line.startswith("#"):
# We found another header - continue processing from here
break
i += 1
# Continue processing from the next header (don't increment i again)
continue
# Add all other lines (including subsequent sections)
result_lines.append(line)
i += 1
return "\n".join(result_lines)
def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
"""Prepend content after frontmatter, preserving frontmatter structure."""
# Check if file has frontmatter
if has_frontmatter(current_content):
try:
# Parse and separate frontmatter from body
frontmatter_data = parse_frontmatter(current_content)
body_content = remove_frontmatter(current_content)
# Prepend content to the body
if content and not content.endswith("\n"):
new_body = content + "\n" + body_content
else:
new_body = content + body_content
# Reconstruct file with frontmatter + prepended body
yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
except Exception as e: # pragma: no cover
logger.warning(
f"Failed to parse frontmatter during prepend: {e}"
) # pragma: no cover
# Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
# No frontmatter or parsing failed - do simple prepend # pragma: no cover
if content and not content.endswith("\n"): # pragma: no cover
return content + "\n" + current_content # pragma: no cover
return content + current_content # pragma: no cover
async def move_entity(
self,
identifier: str,
destination_path: str,
project_config: ProjectConfig,
app_config: BasicMemoryConfig,
) -> EntityModel:
"""Move entity to new location with database consistency.
Args:
identifier: Entity identifier (title, permalink, or memory:// URL)
destination_path: New path relative to project root
project_config: Project configuration for file operations
app_config: App configuration for permalink update settings
Returns:
Success message with move details
Raises:
EntityNotFoundError: If the entity cannot be found
ValueError: If move operation fails due to validation or filesystem errors
"""
logger.debug(f"Moving entity: {identifier} to {destination_path}")
# 1. Resolve identifier to entity
entity = await self.link_resolver.resolve_link(identifier)
if not entity:
raise EntityNotFoundError(f"Entity not found: {identifier}")
current_path = entity.file_path
old_permalink = entity.permalink
# 2. Validate destination path format first
if not destination_path or destination_path.startswith("/") or not destination_path.strip():
raise ValueError(f"Invalid destination path: {destination_path}")
# 3. Validate paths
source_file = project_config.home / current_path
destination_file = project_config.home / destination_path
# Validate source exists
if not source_file.exists():
raise ValueError(f"Source file not found: {current_path}")
# Check if destination already exists
if destination_file.exists():
raise ValueError(f"Destination already exists: {destination_path}")
try:
# 4. Create destination directory if needed
destination_file.parent.mkdir(parents=True, exist_ok=True)
# 5. Move physical file
source_file.rename(destination_file)
logger.info(f"Moved file: {current_path} -> {destination_path}")
# 6. Prepare database updates
updates = {"file_path": destination_path}
# 7. Update permalink if configured
if app_config.update_permalinks_on_move:
# Generate new permalink from destination path
new_permalink = await self.resolve_permalink(destination_path)
# Update frontmatter with new permalink
await self.file_service.update_frontmatter(
destination_path, {"permalink": new_permalink}
)
updates["permalink"] = new_permalink
logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
# 8. Recalculate checksum
new_checksum = await self.file_service.compute_checksum(destination_path)
updates["checksum"] = new_checksum
# 9. Update database
updated_entity = await self.repository.update(entity.id, updates)
if not updated_entity:
raise ValueError(f"Failed to update entity in database: {entity.id}")
return updated_entity
except Exception as e:
# Rollback: try to restore original file location if move succeeded
if destination_file.exists() and not source_file.exists():
try:
destination_file.rename(source_file)
logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
except Exception as rollback_error: # pragma: no cover
logger.error(f"Failed to rollback file move: {rollback_error}")
# Re-raise the original error with context
raise ValueError(f"Move failed: {str(e)}") from e
+10 -10
View File
@@ -94,8 +94,8 @@ class FileService:
"""
try:
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
logger.debug(f"Checking file existence: path={path_obj}")
if path_obj.is_absolute():
return path_obj.exists()
else:
@@ -121,7 +121,7 @@ class FileService:
FileOperationError: If write fails
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
@@ -140,7 +140,7 @@ class FileService:
# Compute and return checksum
checksum = await file_utils.compute_checksum(content)
logger.debug("File write completed", path=str(full_path), checksum=checksum)
logger.debug(f"File write completed path={full_path}, {checksum=}")
return checksum
except Exception as e:
@@ -164,7 +164,7 @@ class FileService:
FileOperationError: If read fails
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
@@ -194,7 +194,7 @@ class FileService:
path: Path to delete (Path or string)
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
full_path.unlink(missing_ok=True)
@@ -210,7 +210,7 @@ class FileService:
Checksum of updated file
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
return await file_utils.update_frontmatter(full_path, updates)
@@ -227,7 +227,7 @@ class FileService:
FileError: If checksum computation fails
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
@@ -253,7 +253,7 @@ class FileService:
File statistics
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
return full_path.stat()
@@ -268,7 +268,7 @@ class FileService:
MIME type of the file
"""
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# get file timestamps
mime_type, _ = mimetypes.guess_type(full_path.name)
+14 -6
View File
@@ -15,10 +15,10 @@ class LinkResolver:
Uses a combination of exact matching and search-based resolution:
1. Try exact permalink match (fastest)
2. Try permalink pattern match (for wildcards)
3. Try exact title match
4. Fall back to search for fuzzy matching
5. Generate new permalink if no match found
2. Try exact title match
3. Try exact file path match
4. Try file path with .md extension (for folder/title patterns)
5. Fall back to search for fuzzy matching
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -52,11 +52,19 @@ class LinkResolver:
logger.debug(f"Found entity with path: {found_path.file_path}")
return found_path
# 4. Try file path with .md extension if not already present
if not clean_text.endswith(".md") and "/" in clean_text:
file_path_with_md = f"{clean_text}.md"
found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
if found_path_md:
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md
# search if indicated
if use_search and "*" not in clean_text:
# 3. Fall back to search for fuzzy matching on title
# 5. Fall back to search for fuzzy matching on title (use text search for prefix matching)
results = await self.search_service.search(
query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]),
query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
)
if results:
+35 -25
View File
@@ -4,12 +4,13 @@ import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
from typing import Dict, Optional, Sequence
from loguru import logger
from sqlalchemy import text
from basic_memory.config import ConfigManager, config, app_config
from basic_memory.config import config, app_config
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas import (
ActivityMetrics,
@@ -18,15 +19,17 @@ from basic_memory.schemas import (
SystemStatus,
)
from basic_memory.config import WATCH_STATUS_JSON
from basic_memory.utils import generate_permalink
from basic_memory.config import config_manager
class ProjectService:
"""Service for managing Basic Memory projects."""
def __init__(self, repository: Optional[ProjectRepository] = None):
repository: ProjectRepository
def __init__(self, repository: ProjectRepository):
"""Initialize the project service."""
super().__init__()
self.config_manager = ConfigManager()
self.repository = repository
@property
@@ -36,7 +39,7 @@ class ProjectService:
Returns:
Dict mapping project names to their file paths
"""
return self.config_manager.projects
return config_manager.projects
@property
def default_project(self) -> str:
@@ -45,7 +48,7 @@ class ProjectService:
Returns:
The name of the default project
"""
return self.config_manager.default_project
return config_manager.default_project
@property
def current_project(self) -> str:
@@ -54,7 +57,14 @@ class ProjectService:
Returns:
The name of the current project
"""
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
async def list_projects(self) -> Sequence[Project]:
return await self.repository.find_all()
async def get_project(self, name: str) -> Optional[Project]:
"""Get the file path for a project by name."""
return await self.repository.get_by_name(name)
async def add_project(self, name: str, path: str) -> None:
"""Add a new project to the configuration and database.
@@ -73,13 +83,13 @@ class ProjectService:
resolved_path = os.path.abspath(os.path.expanduser(path))
# First add to config file (this will validate the project doesn't exist)
self.config_manager.add_project(name, resolved_path)
project_config = config_manager.add_project(name, resolved_path)
# Then add to database
project_data = {
"name": name,
"path": resolved_path,
"permalink": name.lower().replace(" ", "-"),
"permalink": generate_permalink(project_config.name),
"is_active": True,
"is_default": False,
}
@@ -100,7 +110,7 @@ class ProjectService:
raise ValueError("Repository is required for remove_project")
# First remove from config (this will validate the project exists and is not default)
self.config_manager.remove_project(name)
config_manager.remove_project(name)
# Then remove from database
project = await self.repository.get_by_name(name)
@@ -122,7 +132,7 @@ class ProjectService:
raise ValueError("Repository is required for set_default_project")
# First update config file (this will validate the project exists)
self.config_manager.set_default_project(name)
config_manager.set_default_project(name)
# Then update database
project = await self.repository.get_by_name(name)
@@ -150,7 +160,7 @@ class ProjectService:
db_projects_by_name = {p.name: p for p in db_projects}
# Get all projects from configuration
config_projects = self.config_manager.projects
config_projects = config_manager.projects
# Add projects that exist in config but not in DB
for name, path in config_projects.items():
@@ -161,7 +171,7 @@ class ProjectService:
"path": path,
"permalink": name.lower().replace(" ", "-"),
"is_active": True,
"is_default": (name == self.config_manager.default_project),
"is_default": (name == config_manager.default_project),
}
await self.repository.create(project_data)
@@ -169,16 +179,16 @@ class ProjectService:
for name, project in db_projects_by_name.items():
if name not in config_projects:
logger.info(f"Adding project '{name}' to configuration")
self.config_manager.add_project(name, project.path)
config_manager.add_project(name, project.path)
# Make sure default project is synchronized
db_default = next((p for p in db_projects if p.is_default), None)
config_default = self.config_manager.default_project
config_default = config_manager.default_project
if db_default and db_default.name != config_default:
# Update config to match DB default
logger.info(f"Updating default project in config to '{db_default.name}'")
self.config_manager.set_default_project(db_default.name)
config_manager.set_default_project(db_default.name)
elif not db_default and config_default in db_projects_by_name:
# Update DB to match config default
logger.info(f"Updating default project in database to '{config_default}'")
@@ -204,7 +214,7 @@ class ProjectService:
raise ValueError("Repository is required for update_project")
# Validate project exists in config
if name not in self.config_manager.projects:
if name not in config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")
# Get project from database
@@ -218,10 +228,10 @@ class ProjectService:
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
# Update in config
projects = self.config_manager.config.projects.copy()
projects = config_manager.config.projects.copy()
projects[name] = resolved_path
self.config_manager.config.projects = projects
self.config_manager.save_config(self.config_manager.config)
config_manager.config.projects = projects
config_manager.save_config(config_manager.config)
# Update in database
project.path = resolved_path
@@ -242,7 +252,7 @@ class ProjectService:
if active_projects:
new_default = active_projects[0]
await self.repository.set_as_default(new_default.id)
self.config_manager.set_default_project(new_default.name)
config_manager.set_default_project(new_default.name)
logger.info(
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
)
@@ -274,11 +284,11 @@ class ProjectService:
db_projects_by_name = {p.name: p for p in db_projects}
# Get default project info
default_project = self.config_manager.default_project
default_project = config_manager.default_project
# Convert config projects to include database info
enhanced_projects = {}
for name, path in self.config_manager.projects.items():
for name, path in config_manager.projects.items():
db_project = db_projects_by_name.get(name)
enhanced_projects[name] = {
"path": path,
@@ -535,4 +545,4 @@ class ProjectService:
database_size=db_size_readable,
watch_status=watch_status,
timestamp=datetime.now(),
)
)
@@ -1,5 +1,6 @@
"""Service for search operations."""
import ast
from datetime import datetime
from typing import List, Optional, Set
@@ -117,6 +118,38 @@ class SearchService:
return variants
def _extract_entity_tags(self, entity: Entity) -> List[str]:
"""Extract tags from entity metadata for search indexing.
Handles multiple tag formats:
- List format: ["tag1", "tag2"]
- String format: "['tag1', 'tag2']" or "[tag1, tag2]"
- Empty: [] or "[]"
Returns a list of tag strings for search indexing.
"""
if not entity.entity_metadata or "tags" not in entity.entity_metadata:
return []
tags = entity.entity_metadata["tags"]
# Handle list format (preferred)
if isinstance(tags, list):
return [str(tag) for tag in tags if tag]
# Handle string format (legacy)
if isinstance(tags, str):
try:
# Parse string representation of list
parsed_tags = ast.literal_eval(tags)
if isinstance(parsed_tags, list):
return [str(tag) for tag in parsed_tags if tag]
except (ValueError, SyntaxError):
# If parsing fails, treat as single tag
return [tags] if tags.strip() else []
return [] # pragma: no cover
async def index_entity(
self,
entity: Entity,
@@ -201,6 +234,11 @@ class SearchService:
content_stems.extend(self._generate_variants(entity.file_path))
# Add entity tags from frontmatter to search content
entity_tags = self._extract_entity_tags(entity)
if entity_tags:
content_stems.extend(entity_tags)
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
# Index entity
@@ -286,3 +324,32 @@ class SearchService:
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index."""
await self.repository.delete_by_entity_id(entity_id)
async def handle_delete(self, entity: Entity):
"""Handle complete entity deletion from search index including observations and relations.
This replicates the logic from sync_service.handle_delete() to properly clean up
all search index entries for an entity and its related data.
"""
logger.debug(
f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
)
# Clean up search index - same logic as sync_service.handle_delete()
permalinks = (
[entity.permalink]
+ [o.permalink for o in entity.observations]
+ [r.permalink for r in entity.outgoing_relations]
)
logger.debug(
f"Deleting search index entries for entity_id={entity.id}, "
f"index_entries={len(permalinks)}"
)
for permalink in permalinks:
if permalink:
await self.delete_by_permalink(permalink)
else:
await self.delete_by_entity_id(entity.id)
+2 -2
View File
@@ -379,7 +379,7 @@ class SyncService:
updates = {"file_path": new_path}
# If configured, also update permalink to match new path
if self.app_config.update_permalinks_on_move:
if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(new_path):
# generate new permalink value
new_permalink = await self.entity_service.resolve_permalink(new_path)
@@ -505,4 +505,4 @@ class SyncService:
f"duration_ms={duration_ms}"
)
return result
return result