Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ postgres-reset:
postgres-migrate:
@cd src/basic_memory/alembic && \
BASIC_MEMORY_DATABASE_BACKEND=postgres \
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
uv run alembic upgrade head
@echo "✅ Migrations applied to Postgres test database"

Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/api/v2/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async def resolve_identifier(
entity = await link_resolver.resolve_link(data.identifier)
if not entity:
raise HTTPException(
status_code=404, detail=f"Could not resolve identifier: '{data.identifier}'"
status_code=404, detail=f"Entity not found: '{data.identifier}'"
)

# Determine resolution method
Expand Down
82 changes: 81 additions & 1 deletion src/basic_memory/api/v2/routers/project_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,91 @@
ProjectItem,
ProjectStatusResponse,
)
from basic_memory.utils import normalize_project_path
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
from basic_memory.utils import normalize_project_path, generate_permalink

router = APIRouter(prefix="/projects", tags=["project_management-v2"])


@router.post("/resolve", response_model=ProjectResolveResponse)
async def resolve_project_identifier(
data: ProjectResolveRequest,
project_repository: ProjectRepositoryDep,
) -> ProjectResolveResponse:
"""Resolve a project identifier (name or permalink) to a project ID.

This endpoint provides efficient lookup of projects by name without
needing to fetch the entire project list. Supports case-insensitive
matching on both name and permalink.

Args:
data: Request containing the identifier to resolve

Returns:
Project information including the numeric ID

Raises:
HTTPException: 404 if project not found

Example:
POST /v2/projects/resolve
{"identifier": "my-project"}

Returns:
{
"project_id": 1,
"name": "my-project",
"permalink": "my-project",
"path": "/path/to/project",
"is_active": true,
"is_default": false,
"resolution_method": "name"
}
"""
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")

# Generate permalink for comparison
identifier_permalink = generate_permalink(data.identifier)

# Try to find project by ID first (if identifier is numeric)
resolution_method = "name"
project = None

if data.identifier.isdigit():
project_id = int(data.identifier)
project = await project_repository.get_by_id(project_id)
if project:
resolution_method = "id"

# If not found by ID, try by permalink first (exact match)
if not project:
project = await project_repository.get_by_permalink(identifier_permalink)
if project:
resolution_method = "permalink"

# If not found by permalink, try case-insensitive name search
# Uses efficient database query instead of fetching all projects
if not project:
project = await project_repository.get_by_name_case_insensitive(data.identifier)
if project:
resolution_method = "name"

if not project:
raise HTTPException(
status_code=404, detail=f"Project not found: '{data.identifier}'"
)

return ProjectResolveResponse(
project_id=project.id,
name=project.name,
permalink=generate_permalink(project.name),
path=normalize_project_path(project.path),
is_active=project.is_active if hasattr(project, "is_active") else True,
is_default=project.is_default or False,
resolution_method=resolution_method,
)


@router.get("/{project_id}", response_model=ProjectItem)
async def get_project_by_id(
project_id: ProjectIdPathDep,
Expand Down
29 changes: 20 additions & 9 deletions src/basic_memory/cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,9 @@

from rich.panel import Panel
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get
from basic_memory.schemas.project_info import ProjectList
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.project_info import ProjectStatusResponse
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
from basic_memory.utils import generate_permalink, normalize_project_path
from basic_memory.mcp.tools.utils import call_patch

# Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import (
Expand Down Expand Up @@ -254,9 +249,17 @@ def remove_project(

async def _remove_project():
async with get_client() as client:
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name)

# Use v2 project resolver to find project ID by permalink
resolve_data = {"identifier": project_permalink}
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
target_project = response.json()

# Use v2 API with project ID
response = await call_delete(
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
client, f"/v2/projects/{target_project['project_id']}?delete_notes={delete_notes}"
)
return ProjectStatusResponse.model_validate(response.json())

Expand Down Expand Up @@ -329,8 +332,16 @@ def set_default_project(

async def _set_default():
async with get_client() as client:
# Convert name to permalink for efficient resolution
project_permalink = generate_permalink(name)
response = await call_put(client, f"/projects/{project_permalink}/default")

# Use v2 project resolver to find project ID by permalink
resolve_data = {"identifier": project_permalink}
response = await call_post(client, "/v2/projects/resolve", json=resolve_data)
target_project = response.json()

# Use v2 API with project ID
response = await call_put(client, f"/v2/projects/{target_project['project_id']}/default")
return ProjectStatusResponse.model_validate(response.json())

try:
Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ async def get_entity_service(
entity_parser: EntityParserDep,
file_service: FileServiceDep,
link_resolver: "LinkResolverDep",
search_service: "SearchServiceDep",
app_config: AppConfigDep,
) -> EntityService:
"""Create EntityService with repository."""
Expand All @@ -408,6 +409,7 @@ async def get_entity_service(
entity_parser=entity_parser,
file_service=file_service,
link_resolver=link_resolver,
search_service=search_service,
app_config=app_config,
)

Expand All @@ -422,6 +424,7 @@ async def get_entity_service_v2(
entity_parser: EntityParserV2Dep,
file_service: FileServiceV2Dep,
link_resolver: "LinkResolverV2Dep",
search_service: "SearchServiceV2Dep",
app_config: AppConfigDep,
) -> EntityService:
"""Create EntityService for v2 API."""
Expand All @@ -432,6 +435,7 @@ async def get_entity_service_v2(
entity_parser=entity_parser,
file_service=file_service,
link_resolver=link_resolver,
search_service=search_service,
app_config=app_config,
)

Expand Down
4 changes: 1 addition & 3 deletions src/basic_memory/mcp/tools/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,9 @@ async def build_context(
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)

project_url = active_project.project_url

response = await call_get(
client,
f"{project_url}/memory/{memory_url_path(url)}",
f"/v2/projects/{active_project.id}/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
Expand Down
40 changes: 28 additions & 12 deletions src/basic_memory/mcp/tools/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id, ToolError


@mcp.tool(
Expand Down Expand Up @@ -96,7 +96,6 @@ async def canvas(
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url

# Ensure path has .canvas extension
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
Expand All @@ -108,23 +107,40 @@ async def canvas(
# Convert to JSON
canvas_json = json.dumps(canvas_data, indent=2)

# Write the file using the resource API
# Try to create the canvas file first (optimistic create)
logger.info(f"Creating canvas file: {file_path} in project {project}")
# Send canvas_json as content string, not as json parameter
# The resource endpoint expects Body() string content, not JSON-encoded data
response = await call_put(
client,
f"{project_url}/resource/{file_path}",
content=canvas_json,
headers={"Content-Type": "text/plain"},
)
try:
response = await call_post(
client,
f"/v2/projects/{active_project.id}/resource",
json={"file_path": file_path, "content": canvas_json},
)
action = "Created"
except Exception as e:
# If creation failed due to conflict (already exists), try to update
if "409" in str(e) or "conflict" in str(e).lower() or "already exists" in str(e).lower():
logger.info(f"Canvas file exists, updating instead: {file_path}")
try:
entity_id = await resolve_entity_id(client, active_project.id, file_path)
# For update, send content in JSON body
response = await call_put(
client,
f"/v2/projects/{active_project.id}/resource/{entity_id}",
json={"content": canvas_json},
)
action = "Updated"
except Exception as update_error:
# Re-raise the original error if update also fails
raise e from update_error
else:
# Re-raise if it's not a conflict error
raise

# Parse response
result = response.json()
logger.debug(result)

# Build summary
action = "Created" if response.status_code == 201 else "Updated"
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]

return "\n".join(summary)
19 changes: 16 additions & 3 deletions src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError

from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.tools.utils import call_delete, resolve_entity_id
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.schemas import DeleteEntitiesResponse
Expand Down Expand Up @@ -204,10 +205,22 @@ async def delete_note(
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url

try:
response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
# Resolve identifier to entity ID
entity_id = await resolve_entity_id(client, active_project.id, identifier)
except ToolError as e:
# If entity not found, return False (note doesn't exist)
if "Entity not found" in str(e) or "not found" in str(e).lower():
logger.warning(f"Note not found for deletion: {identifier}")
return False
# For other resolution errors, return formatted error message
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
return _format_delete_error_response(active_project.name, str(e), identifier)

try:
# Call the DELETE endpoint
response = await call_delete(client, f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}")
result = DeleteEntitiesResponse.model_validate(response.json())

if result.deleted:
Expand Down
8 changes: 5 additions & 3 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_patch
from basic_memory.mcp.tools.utils import call_patch, resolve_entity_id
from basic_memory.schemas import EntityResponse


Expand Down Expand Up @@ -216,7 +216,6 @@ async def edit_note(
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url

logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)

Expand All @@ -235,6 +234,9 @@ async def edit_note(

# Use the PATCH endpoint to edit the entity
try:
# Resolve identifier to entity ID
entity_id = await resolve_entity_id(client, active_project.id, identifier)

# Prepare the edit request data
edit_data = {
"operation": operation,
Expand All @@ -250,7 +252,7 @@ async def edit_note(
edit_data["expected_replacements"] = str(expected_replacements)

# Call the PATCH endpoint
url = f"{project_url}/knowledge/entities/{identifier}"
url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
response = await call_patch(client, url, json=edit_data)
result = EntityResponse.model_validate(response.json())

Expand Down
3 changes: 1 addition & 2 deletions src/basic_memory/mcp/tools/list_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ async def list_directory(
"""
async with get_client() as client:
active_project = await get_active_project(client, project, context)
project_url = active_project.project_url

# Prepare query parameters
params = {
Expand All @@ -82,7 +81,7 @@ async def list_directory(
# Call the API endpoint
response = await call_get(
client,
f"{project_url}/directory/list",
f"/v2/projects/{active_project.id}/directory/list",
params=params,
)

Expand Down
Loading
Loading