mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
05adda1502
Signed-off-by: phernandez <paul@basicmachines.co>
473 lines
19 KiB
Python
473 lines
19 KiB
Python
from textwrap import dedent
|
|
from typing import Annotated, Optional, Literal
|
|
|
|
from loguru import logger
|
|
from fastmcp import Context
|
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
from pydantic import AliasChoices, Field
|
|
|
|
from basic_memory.config import ConfigManager
|
|
from basic_memory.mcp.project_context import (
|
|
detect_project_from_memory_url_prefix,
|
|
get_project_client,
|
|
resolve_project_and_path,
|
|
)
|
|
from basic_memory.mcp.server import mcp
|
|
from basic_memory.schemas.project_info import ProjectItem
|
|
from basic_memory.utils import generate_permalink, normalize_project_reference
|
|
from basic_memory.workspace_context import current_workspace_permalink_context
|
|
|
|
|
|
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
|
"""Format helpful error responses for delete failures that guide users to successful deletions."""
|
|
|
|
# Note not found errors
|
|
if "entity not found" in error_message.lower() or "not found" in error_message.lower():
|
|
search_term = identifier.split("/")[-1] if "/" in identifier else identifier
|
|
title_format = (
|
|
identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
|
|
)
|
|
permalink_format = identifier.lower().replace(" ", "-")
|
|
|
|
return dedent(f"""
|
|
# Delete Failed - Note Not Found
|
|
|
|
The note '{identifier}' could not be found for deletion in {project}.
|
|
|
|
## This might mean:
|
|
1. **Already deleted**: The note may have been deleted previously
|
|
2. **Wrong identifier**: The identifier format might be incorrect
|
|
3. **Different project**: The note might be in a different project
|
|
|
|
## How to verify:
|
|
1. **Search for the note**: Use `search_notes("{project}", "{search_term}")` to find it
|
|
2. **Try different formats**:
|
|
- If you used a permalink like "folder/note-title", try just the title: "{title_format}"
|
|
- If you used a title, try the permalink format: "{permalink_format}"
|
|
|
|
3. **Check if already deleted**: Use `list_directory("/")` to see what notes exist
|
|
4. **List notes in project**: Use `list_directory("/")` to see what notes exist in the current project
|
|
|
|
## If the note actually exists:
|
|
```
|
|
# First, find the correct identifier:
|
|
search_notes("{project}", "{identifier}")
|
|
|
|
# Then delete using the correct identifier:
|
|
delete_note("{project}", "correct-identifier-from-search")
|
|
```
|
|
|
|
## If you want to delete multiple similar notes:
|
|
Use search to find all related notes and delete them one by one.
|
|
""").strip()
|
|
|
|
# Permission/access errors
|
|
if (
|
|
"permission" in error_message.lower()
|
|
or "access" in error_message.lower()
|
|
or "forbidden" in error_message.lower()
|
|
):
|
|
return f"""# Delete Failed - Permission Error
|
|
|
|
You don't have permission to delete '{identifier}': {error_message}
|
|
|
|
## How to resolve:
|
|
1. **Check permissions**: Verify you have delete/write access to this project
|
|
2. **File locks**: The note might be open in another application
|
|
3. **Project access**: Ensure you're in the correct project with proper permissions
|
|
|
|
## Alternative actions:
|
|
- List available projects: `list_memory_projects()`
|
|
- Specify the correct project: `delete_note("{identifier}", project="project-name")`
|
|
- Verify note exists first: `read_note("{identifier}", project="project-name")`
|
|
|
|
## If you have read-only access:
|
|
Ask someone with write access to delete the note."""
|
|
|
|
# Server/filesystem errors
|
|
if (
|
|
"server error" in error_message.lower()
|
|
or "filesystem" in error_message.lower()
|
|
or "disk" in error_message.lower()
|
|
):
|
|
return f"""# Delete Failed - System Error
|
|
|
|
A system error occurred while deleting '{identifier}': {error_message}
|
|
|
|
## Immediate steps:
|
|
1. **Try again**: The error might be temporary
|
|
2. **Check file status**: Verify the file isn't locked or in use
|
|
3. **Check disk space**: Ensure the system has adequate storage
|
|
|
|
## Troubleshooting:
|
|
- Verify note exists: `read_note("{project}","{identifier}")`
|
|
- Try again in a few moments
|
|
|
|
## If problem persists:
|
|
Send a message to support@basicmachines.co - there may be a filesystem or database issue."""
|
|
|
|
# Database/sync errors
|
|
if "database" in error_message.lower() or "sync" in error_message.lower():
|
|
return f"""# Delete Failed - Database Error
|
|
|
|
A database error occurred while deleting '{identifier}': {error_message}
|
|
|
|
## This usually means:
|
|
1. **Sync conflict**: The file system and database are out of sync
|
|
2. **Database lock**: Another operation is accessing the database
|
|
3. **Corrupted entry**: The database entry might be corrupted
|
|
|
|
## Steps to resolve:
|
|
1. **Try again**: Wait a moment and retry the deletion
|
|
2. **Check note status**: `read_note("{project}","{identifier}")` to see current state
|
|
3. **Manual verification**: Use `list_directory()` to see if file still exists
|
|
|
|
## If the note appears gone but database shows it exists:
|
|
Send a message to support@basicmachines.co - a manual database cleanup may be needed."""
|
|
|
|
# Generic fallback
|
|
return f"""# Delete Failed
|
|
|
|
Error deleting note '{identifier}': {error_message}
|
|
|
|
## General troubleshooting:
|
|
1. **Verify the note exists**: `read_note("{project}", "{identifier}")` or `search_notes("{project}", "{identifier}")`
|
|
2. **Check permissions**: Ensure you can edit/delete files in this project
|
|
3. **Try again**: The error might be temporary
|
|
4. **Check project**: Make sure you're in the correct project
|
|
|
|
## Step-by-step approach:
|
|
```
|
|
# 1. Confirm note exists and get correct identifier
|
|
search_notes("{project}", "{identifier}")
|
|
|
|
# 2. Read the note to verify access
|
|
read_note("{project}", "correct-identifier-from-search")
|
|
|
|
# 3. Try deletion with correct identifier
|
|
delete_note("{project}", "correct-identifier-from-search")
|
|
```
|
|
|
|
## Alternative approaches:
|
|
- Check what notes exist: `list_directory("{project}", "/")`
|
|
|
|
## Need help?
|
|
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
|
|
|
|
|
|
def _directory_path_for_delete(
|
|
target_identifier: str,
|
|
active_project: ProjectItem,
|
|
*,
|
|
include_project_prefix: bool,
|
|
) -> str:
|
|
"""Return the project-relative directory path expected by the delete API."""
|
|
directory = normalize_project_reference(target_identifier).strip("/")
|
|
project_permalink = active_project.permalink
|
|
|
|
route_prefixes: list[str] = []
|
|
workspace_context = current_workspace_permalink_context()
|
|
if workspace_context and workspace_context.should_prefix_permalinks:
|
|
route_prefixes.append(
|
|
f"{generate_permalink(workspace_context.workspace_slug)}/{project_permalink}"
|
|
)
|
|
if include_project_prefix:
|
|
route_prefixes.append(project_permalink)
|
|
|
|
for route_prefix in route_prefixes:
|
|
if directory.startswith(f"{route_prefix}/"):
|
|
return directory.removeprefix(f"{route_prefix}/")
|
|
|
|
return directory
|
|
|
|
|
|
@mcp.tool(
|
|
description="Delete a note or directory by title, permalink, or path",
|
|
annotations={"destructiveHint": True, "openWorldHint": False},
|
|
)
|
|
async def delete_note(
|
|
identifier: str,
|
|
is_directory: Annotated[
|
|
bool,
|
|
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
|
|
] = False,
|
|
project: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
output_format: Literal["text", "json"] = "text",
|
|
context: Context | None = None,
|
|
) -> bool | str | dict:
|
|
"""Delete a note or directory from the knowledge base.
|
|
|
|
Permanently removes a note or directory from the specified project. For single notes,
|
|
they are identified by title or permalink. For directories, use is_directory=True and
|
|
provide the directory path. If the note/directory doesn't exist, the operation returns
|
|
False without error. If deletion fails, helpful error messages are provided.
|
|
|
|
Project Resolution:
|
|
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
|
If project unknown, use list_memory_projects() or recent_activity() first.
|
|
|
|
Args:
|
|
identifier: For files: note title or permalink to delete.
|
|
For directories: the directory path (e.g., "docs", "projects/2025").
|
|
Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
|
|
is_directory: If True, deletes an entire directory and all its contents.
|
|
When True, identifier should be a directory path
|
|
(without file extensions). Defaults to False.
|
|
project: Project name to delete from. Optional - server will resolve using hierarchy.
|
|
If unknown, use list_memory_projects() to discover available projects.
|
|
project_id: Project external_id (UUID). Prefer this over `project` when known —
|
|
it routes to the exact project regardless of name collisions across cloud
|
|
workspaces. Takes precedence over `project`. Get from list_memory_projects().
|
|
output_format: "text" preserves existing behavior (bool/string). "json"
|
|
returns machine-readable deletion metadata.
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
True if note was successfully deleted, False if note was not found.
|
|
For directories, returns a formatted summary of deleted files.
|
|
On errors, returns a formatted string with helpful troubleshooting guidance.
|
|
|
|
Examples:
|
|
# Delete by title
|
|
delete_note("Meeting Notes: Project Planning")
|
|
|
|
# Delete by permalink
|
|
delete_note("notes/project-planning")
|
|
|
|
# Delete with explicit project
|
|
delete_note("experiments/ml-model-results", project="research")
|
|
|
|
# Delete entire directory
|
|
delete_note("docs", is_directory=True)
|
|
|
|
# Delete nested directory
|
|
delete_note("projects/2024", is_directory=True)
|
|
|
|
# Common usage pattern
|
|
if delete_note("old-draft"):
|
|
print("Note deleted successfully")
|
|
else:
|
|
print("Note not found or already deleted")
|
|
|
|
Raises:
|
|
HTTPError: If project doesn't exist or is inaccessible
|
|
SecurityError: If identifier attempts path traversal
|
|
|
|
Warning:
|
|
This operation is permanent and cannot be undone. The note/directory files
|
|
will be removed from the filesystem and all references will be lost.
|
|
|
|
Note:
|
|
If the note is not found, this function provides helpful error messages
|
|
with suggestions for finding the correct identifier, including search
|
|
commands and alternative formats to try.
|
|
"""
|
|
# Detect project from memory URL prefix before routing
|
|
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
|
|
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
|
|
# where "research" is a directory, not a project name
|
|
# Outcome: project is set from the URL prefix, routing goes to the correct project
|
|
if project is None and project_id is None and identifier.strip().startswith("memory://"):
|
|
detected = await detect_project_from_memory_url_prefix(
|
|
identifier,
|
|
ConfigManager().config,
|
|
context=context,
|
|
)
|
|
if detected:
|
|
project = detected
|
|
|
|
async with get_project_client(project, context=context, project_id=project_id) as (
|
|
client,
|
|
active_project,
|
|
):
|
|
logger.debug(
|
|
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
|
|
)
|
|
|
|
# Import here to avoid circular import
|
|
from basic_memory.mcp.clients import KnowledgeClient
|
|
|
|
# Use typed KnowledgeClient for API calls
|
|
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
_, target_identifier, is_memory_url = await resolve_project_and_path(
|
|
client,
|
|
identifier,
|
|
active_project.name,
|
|
context,
|
|
)
|
|
|
|
# Handle directory deletes
|
|
if is_directory:
|
|
try:
|
|
# Trigger: directory input was routed from a memory:// URL.
|
|
# Why: resolve_project_and_path returns canonical permalinks, while
|
|
# delete_directory filters by project-relative file_path prefixes.
|
|
# Outcome: strip only the route prefix before calling the delete API.
|
|
directory_identifier = (
|
|
_directory_path_for_delete(
|
|
target_identifier,
|
|
active_project,
|
|
include_project_prefix=ConfigManager().config.permalinks_include_project,
|
|
)
|
|
if is_memory_url
|
|
else target_identifier
|
|
)
|
|
result = await knowledge_client.delete_directory(directory_identifier)
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": result.failed_deletes == 0,
|
|
"is_directory": True,
|
|
"identifier": identifier,
|
|
"total_files": result.total_files,
|
|
"successful_deletes": result.successful_deletes,
|
|
"failed_deletes": result.failed_deletes,
|
|
}
|
|
|
|
# Build success message for directory delete
|
|
result_lines = [
|
|
"# Directory Deleted Successfully",
|
|
"",
|
|
f"**Directory:** `{identifier}`",
|
|
"",
|
|
"## Summary",
|
|
f"- Total files: {result.total_files}",
|
|
f"- Successfully deleted: {result.successful_deletes}",
|
|
f"- Failed: {result.failed_deletes}",
|
|
]
|
|
|
|
if result.deleted_files:
|
|
result_lines.extend(["", "## Deleted Files"])
|
|
for file_path in result.deleted_files[:10]: # Show first 10
|
|
result_lines.append(f"- `{file_path}`")
|
|
if len(result.deleted_files) > 10:
|
|
result_lines.append(f"- ... and {len(result.deleted_files) - 10} more")
|
|
|
|
if result.errors: # pragma: no cover
|
|
result_lines.extend(["", "## Errors"])
|
|
for error in result.errors[:5]: # Show first 5 errors
|
|
result_lines.append(f"- `{error.path}`: {error.error}")
|
|
if len(result.errors) > 5:
|
|
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
|
|
|
|
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
|
|
|
|
logger.info(
|
|
f"Directory delete completed: {identifier}, "
|
|
f"deleted={result.successful_deletes}, failed={result.failed_deletes}"
|
|
)
|
|
|
|
return "\n".join(result_lines)
|
|
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Directory delete failed for '{identifier}': {e}")
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": False,
|
|
"is_directory": True,
|
|
"identifier": identifier,
|
|
"total_files": 0,
|
|
"successful_deletes": 0,
|
|
"failed_deletes": 0,
|
|
"error": str(e),
|
|
}
|
|
return f"""# Directory Delete Failed
|
|
|
|
Error deleting directory '{identifier}': {str(e)}
|
|
|
|
## Troubleshooting:
|
|
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
|
|
2. **Check for permission issues**: Ensure you have delete access to the project
|
|
3. **Try individual deletes**: Delete files one at a time if bulk delete fails
|
|
|
|
## Alternative approach:
|
|
```
|
|
# List directory contents first
|
|
list_directory("{identifier}")
|
|
|
|
# Then delete individual files
|
|
delete_note("path/to/file.md")
|
|
```"""
|
|
|
|
# Handle single note deletes
|
|
note_title = None
|
|
note_permalink = None
|
|
note_file_path = None
|
|
try:
|
|
# Resolve identifier to entity ID
|
|
entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True)
|
|
if output_format == "json":
|
|
entity = await knowledge_client.get_entity(entity_id)
|
|
note_title = entity.title
|
|
note_permalink = entity.permalink
|
|
note_file_path = entity.file_path
|
|
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}")
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": False,
|
|
"title": None,
|
|
"permalink": None,
|
|
"file_path": None,
|
|
}
|
|
return False
|
|
# For other resolution errors, return formatted error message
|
|
logger.error( # pragma: no cover
|
|
f"Delete failed for '{identifier}': {e}, project: {active_project.name}"
|
|
)
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": False,
|
|
"title": None,
|
|
"permalink": None,
|
|
"file_path": None,
|
|
"error": str(e),
|
|
}
|
|
return _format_delete_error_response( # pragma: no cover
|
|
active_project.name, str(e), identifier
|
|
)
|
|
|
|
try:
|
|
# Call the DELETE endpoint
|
|
result = await knowledge_client.delete_entity(entity_id)
|
|
|
|
if result.deleted:
|
|
logger.info(
|
|
f"Successfully deleted note: {identifier} in project: {active_project.name}"
|
|
)
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": True,
|
|
"title": note_title,
|
|
"permalink": note_permalink,
|
|
"file_path": note_file_path,
|
|
}
|
|
return True
|
|
else:
|
|
logger.warning( # pragma: no cover
|
|
f"Delete operation completed but note was not deleted: {identifier}"
|
|
)
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": False,
|
|
"title": note_title,
|
|
"permalink": note_permalink,
|
|
"file_path": note_file_path,
|
|
}
|
|
return False # pragma: no cover
|
|
|
|
except Exception as e: # pragma: no cover
|
|
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
|
|
if output_format == "json":
|
|
return {
|
|
"deleted": False,
|
|
"title": note_title,
|
|
"permalink": note_permalink,
|
|
"file_path": note_file_path,
|
|
"error": str(e),
|
|
}
|
|
# Return formatted error message for better user experience
|
|
return _format_delete_error_response(active_project.name, str(e), identifier)
|