|
7 | 7 |
|
8 | 8 | from basic_memory.mcp.async_client import client |
9 | 9 | from basic_memory.mcp.server import mcp |
10 | | -from basic_memory.mcp.tools.utils import call_post |
| 10 | +from basic_memory.mcp.tools.utils import call_post, call_get |
11 | 11 | from basic_memory.mcp.project_session import get_active_project |
12 | 12 | from basic_memory.schemas import EntityResponse |
| 13 | +from basic_memory.schemas.project_info import ProjectList |
| 14 | + |
| 15 | + |
| 16 | +async def _detect_cross_project_move_attempt( |
| 17 | + identifier: str, destination_path: str, current_project: str |
| 18 | +) -> Optional[str]: |
| 19 | + """Detect potential cross-project move attempts and return guidance. |
| 20 | + |
| 21 | + Args: |
| 22 | + identifier: The note identifier being moved |
| 23 | + destination_path: The destination path |
| 24 | + current_project: The current active project |
| 25 | + |
| 26 | + Returns: |
| 27 | + Error message with guidance if cross-project move is detected, None otherwise |
| 28 | + """ |
| 29 | + try: |
| 30 | + # Get list of all available projects to check against |
| 31 | + response = await call_get(client, "/projects/projects") |
| 32 | + project_list = ProjectList.model_validate(response.json()) |
| 33 | + project_names = [p.name.lower() for p in project_list.projects] |
| 34 | + |
| 35 | + # Check if destination path contains any project names |
| 36 | + dest_lower = destination_path.lower() |
| 37 | + path_parts = dest_lower.split("/") |
| 38 | + |
| 39 | + # Look for project names in the destination path |
| 40 | + for part in path_parts: |
| 41 | + if part in project_names and part != current_project.lower(): |
| 42 | + # Found a different project name in the path |
| 43 | + matching_project = next(p.name for p in project_list.projects if p.name.lower() == part) |
| 44 | + return _format_cross_project_error_response( |
| 45 | + identifier, destination_path, current_project, matching_project |
| 46 | + ) |
| 47 | + |
| 48 | + # Check if the destination path looks like it might be trying to reference another project |
| 49 | + # (e.g., contains common project-like patterns) |
| 50 | + if any(keyword in dest_lower for keyword in ["project", "workspace", "repo"]): |
| 51 | + # This might be a cross-project attempt, but we can't be sure |
| 52 | + # Return a general guidance message |
| 53 | + available_projects = [p.name for p in project_list.projects if p.name != current_project] |
| 54 | + if available_projects: |
| 55 | + return _format_potential_cross_project_guidance( |
| 56 | + identifier, destination_path, current_project, available_projects |
| 57 | + ) |
| 58 | + |
| 59 | + except Exception as e: |
| 60 | + # If we can't detect, don't interfere with normal error handling |
| 61 | + logger.debug(f"Could not check for cross-project move: {e}") |
| 62 | + return None |
| 63 | + |
| 64 | + return None |
| 65 | + |
| 66 | + |
| 67 | +def _format_cross_project_error_response( |
| 68 | + identifier: str, destination_path: str, current_project: str, target_project: str |
| 69 | +) -> str: |
| 70 | + """Format error response for detected cross-project move attempts.""" |
| 71 | + return dedent(f""" |
| 72 | + # Move Failed - Cross-Project Move Not Supported |
| 73 | + |
| 74 | + Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}'). |
| 75 | + |
| 76 | + **Current project:** {current_project} |
| 77 | + **Target project:** {target_project} |
| 78 | + |
| 79 | + ## Cross-project moves are not supported directly |
| 80 | + |
| 81 | + Notes can only be moved within the same project. To move content between projects, use this workflow: |
| 82 | + |
| 83 | + ### Recommended approach: |
| 84 | + ``` |
| 85 | + # 1. Read the note content from current project |
| 86 | + read_note("{identifier}") |
| 87 | + |
| 88 | + # 2. Switch to the target project |
| 89 | + switch_project("{target_project}") |
| 90 | + |
| 91 | + # 3. Create the note in the target project |
| 92 | + write_note("Note Title", "content from step 1", "target-folder") |
| 93 | + |
| 94 | + # 4. Switch back to original project (optional) |
| 95 | + switch_project("{current_project}") |
| 96 | + |
| 97 | + # 5. Delete the original note if desired |
| 98 | + delete_note("{identifier}") |
| 99 | + ``` |
| 100 | + |
| 101 | + ### Alternative: Stay in current project |
| 102 | + If you want to move the note within the **{current_project}** project only: |
| 103 | + ``` |
| 104 | + move_note("{identifier}", "new-folder/new-name.md") |
| 105 | + ``` |
| 106 | + |
| 107 | + ## Available projects: |
| 108 | + Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects. |
| 109 | + """).strip() |
| 110 | + |
| 111 | + |
| 112 | +def _format_potential_cross_project_guidance( |
| 113 | + identifier: str, destination_path: str, current_project: str, available_projects: list[str] |
| 114 | +) -> str: |
| 115 | + """Format guidance for potentially cross-project moves.""" |
| 116 | + other_projects = ", ".join(available_projects[:3]) # Show first 3 projects |
| 117 | + if len(available_projects) > 3: |
| 118 | + other_projects += f" (and {len(available_projects) - 3} others)" |
| 119 | + |
| 120 | + return dedent(f""" |
| 121 | + # Move Failed - Check Project Context |
| 122 | + |
| 123 | + Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'. |
| 124 | + |
| 125 | + ## If you intended to move within the current project: |
| 126 | + The destination path should be relative to the project root: |
| 127 | + ``` |
| 128 | + move_note("{identifier}", "folder/filename.md") |
| 129 | + ``` |
| 130 | + |
| 131 | + ## If you intended to move to a different project: |
| 132 | + Cross-project moves require switching projects first. Available projects: {other_projects} |
| 133 | + |
| 134 | + ### To move to another project: |
| 135 | + ``` |
| 136 | + # 1. Read the content |
| 137 | + read_note("{identifier}") |
| 138 | + |
| 139 | + # 2. Switch to target project |
| 140 | + switch_project("target-project-name") |
| 141 | + |
| 142 | + # 3. Create note in target project |
| 143 | + write_note("Title", "content", "folder") |
| 144 | + |
| 145 | + # 4. Switch back and delete original if desired |
| 146 | + switch_project("{current_project}") |
| 147 | + delete_note("{identifier}") |
| 148 | + ``` |
| 149 | + |
| 150 | + ### To see all projects: |
| 151 | + ``` |
| 152 | + list_projects() |
| 153 | + ``` |
| 154 | + """).strip() |
13 | 155 |
|
14 | 156 |
|
15 | 157 | def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str: |
@@ -258,6 +400,14 @@ async def move_note( |
258 | 400 | active_project = get_active_project(project) |
259 | 401 | project_url = active_project.project_url |
260 | 402 |
|
| 403 | + # Check for potential cross-project move attempts |
| 404 | + cross_project_error = await _detect_cross_project_move_attempt( |
| 405 | + identifier, destination_path, active_project.name |
| 406 | + ) |
| 407 | + if cross_project_error: |
| 408 | + logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}") |
| 409 | + return cross_project_error |
| 410 | + |
261 | 411 | try: |
262 | 412 | # Prepare move request |
263 | 413 | move_data = { |
|
0 commit comments