basic-memory 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,551 @@
1
+ """Move note tool for Basic Memory MCP server."""
2
+
3
+ from textwrap import dedent
4
+ from typing import Optional
5
+
6
+ from loguru import logger
7
+ from fastmcp import Context
8
+
9
+ from basic_memory.mcp.async_client import get_client
10
+ from basic_memory.mcp.server import mcp
11
+ from basic_memory.mcp.tools.utils import call_get, call_put, resolve_entity_id
12
+ from basic_memory.mcp.project_context import get_active_project
13
+ from basic_memory.schemas import EntityResponse
14
+ from basic_memory.schemas.project_info import ProjectList
15
+ from basic_memory.telemetry import track_mcp_tool
16
+ from basic_memory.utils import validate_project_path
17
+
18
+
19
+ async def _detect_cross_project_move_attempt(
20
+ client, identifier: str, destination_path: str, current_project: str
21
+ ) -> Optional[str]:
22
+ """Detect potential cross-project move attempts and return guidance.
23
+
24
+ Args:
25
+ client: The AsyncClient instance
26
+ identifier: The note identifier being moved
27
+ destination_path: The destination path
28
+ current_project: The current active project
29
+
30
+ Returns:
31
+ Error message with guidance if cross-project move is detected, None otherwise
32
+ """
33
+ try:
34
+ # Get list of all available projects to check against
35
+ response = await call_get(client, "/projects/projects")
36
+ project_list = ProjectList.model_validate(response.json())
37
+ project_names = [p.name.lower() for p in project_list.projects]
38
+
39
+ # Check if destination path contains any project names
40
+ dest_lower = destination_path.lower()
41
+ path_parts = dest_lower.split("/")
42
+
43
+ # Look for project names in the destination path
44
+ for part in path_parts:
45
+ if part in project_names and part != current_project.lower():
46
+ # Found a different project name in the path
47
+ matching_project = next(
48
+ p.name for p in project_list.projects if p.name.lower() == part
49
+ )
50
+ return _format_cross_project_error_response(
51
+ identifier, destination_path, current_project, matching_project
52
+ )
53
+
54
+ # No other cross-project patterns detected
55
+
56
+ except Exception as e:
57
+ # If we can't detect, don't interfere with normal error handling
58
+ logger.debug(f"Could not check for cross-project move: {e}")
59
+ return None
60
+
61
+ return None
62
+
63
+
64
+ def _format_cross_project_error_response(
65
+ identifier: str, destination_path: str, current_project: str, target_project: str
66
+ ) -> str:
67
+ """Format error response for detected cross-project move attempts."""
68
+ return dedent(f"""
69
+ # Move Failed - Cross-Project Move Not Supported
70
+
71
+ Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
72
+
73
+ **Current project:** {current_project}
74
+ **Target project:** {target_project}
75
+
76
+ ## Cross-project moves are not supported directly
77
+
78
+ Notes can only be moved within the same project. To move content between projects, use this workflow:
79
+
80
+ ### Recommended approach:
81
+ ```
82
+ # 1. Read the note content from current project
83
+ read_note("{identifier}")
84
+
85
+ # 2. Create the note in the target project
86
+ write_note("Note Title", "content from step 1", "target-folder", project="{target_project}")
87
+
88
+ # 3. Delete the original note if desired
89
+ delete_note("{identifier}", project="{current_project}")
90
+
91
+ ```
92
+
93
+ ### Alternative: Stay in current project
94
+ If you want to move the note within the **{current_project}** project only:
95
+ ```
96
+ move_note("{identifier}", "new-folder/new-name.md")
97
+ ```
98
+
99
+ ## Available projects:
100
+ Use `list_memory_projects()` to see all available projects.
101
+ """).strip()
102
+
103
+
104
+ def _format_potential_cross_project_guidance(
105
+ identifier: str, destination_path: str, current_project: str, available_projects: list[str]
106
+ ) -> str:
107
+ """Format guidance for potentially cross-project moves."""
108
+ other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
109
+ if len(available_projects) > 3:
110
+ other_projects += f" (and {len(available_projects) - 3} others)"
111
+
112
+ return dedent(f"""
113
+ # Move Failed - Check Project Context
114
+
115
+ Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
116
+
117
+ ## If you intended to move within the current project:
118
+ The destination path should be relative to the project root:
119
+ ```
120
+ move_note("{identifier}", "folder/filename.md")
121
+ ```
122
+
123
+ ## If you intended to move to a different project:
124
+ Cross-project moves require switching projects first. Available projects: {other_projects}
125
+
126
+ ### To move to another project:
127
+ ```
128
+ # 1. Read the content
129
+ read_note("{identifier}")
130
+
131
+ # 2. Create note in target project
132
+ write_note("Title", "content", "folder", project="target-project-name")
133
+
134
+ # 3. Delete original if desired
135
+ delete_note("{identifier}", project="{current_project}")
136
+ ```
137
+
138
+ ### To see all projects:
139
+ ```
140
+ list_memory_projects()
141
+ ```
142
+ """).strip()
143
+
144
+
145
+ def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
146
+ """Format helpful error responses for move failures that guide users to successful moves."""
147
+
148
+ # Note not found errors
149
+ if "entity not found" in error_message.lower() or "not found" in error_message.lower():
150
+ search_term = identifier.split("/")[-1] if "/" in identifier else identifier
151
+ title_format = (
152
+ identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
153
+ )
154
+ permalink_format = identifier.lower().replace(" ", "-")
155
+
156
+ return dedent(f"""
157
+ # Move Failed - Note Not Found
158
+
159
+ The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching).
160
+
161
+ ## Suggestions to try:
162
+ 1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers
163
+ 2. **Try different exact identifier formats**:
164
+ - If you used a permalink like "folder/note-title", try the exact title: "{title_format}"
165
+ - If you used a title, try the exact permalink format: "{permalink_format}"
166
+ - Use `read_note()` first to verify the note exists and get the exact identifier
167
+
168
+ 3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
169
+ 4. **List available notes**: Use `list_directory("/")` to see what notes exist
170
+
171
+ ## Before trying again:
172
+ ```
173
+ # First, verify the note exists:
174
+ search_notes("{identifier}")
175
+
176
+ # Then use the exact identifier from search results:
177
+ move_note("correct-identifier-here", "{destination_path}")
178
+ ```
179
+ """).strip()
180
+
181
+ # Destination already exists errors
182
+ if "already exists" in error_message.lower() or "file exists" in error_message.lower():
183
+ return f"""# Move Failed - Destination Already Exists
184
+
185
+ Cannot move '{identifier}' to '{destination_path}' because a file already exists at that location.
186
+
187
+ ## How to resolve:
188
+ 1. **Choose a different destination**: Try a different filename or folder
189
+ - Add timestamp: `{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md`
190
+ - Use different folder: `archive/{destination_path}` or `backup/{destination_path}`
191
+
192
+ 2. **Check the existing file**: Use `read_note("{destination_path}")` to see what's already there
193
+ 3. **Remove or rename existing**: If safe to do so, move the existing file first
194
+
195
+ ## Try these alternatives:
196
+ ```
197
+ # Option 1: Add timestamp to make unique
198
+ move_note("{identifier}", "{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md")
199
+
200
+ # Option 2: Use archive folder
201
+ move_note("{identifier}", "archive/{destination_path}")
202
+
203
+ # Option 3: Check what's at destination first
204
+ read_note("{destination_path}")
205
+ ```"""
206
+
207
+ # Invalid path errors
208
+ if "invalid" in error_message.lower() and "path" in error_message.lower():
209
+ return f"""# Move Failed - Invalid Destination Path
210
+
211
+ The destination path '{destination_path}' is not valid: {error_message}
212
+
213
+ ## Path requirements:
214
+ 1. **Relative paths only**: Don't start with `/` (use `notes/file.md` not `/notes/file.md`)
215
+ 2. **Include file extension**: Add `.md` for markdown files
216
+ 3. **Use forward slashes**: For folder separators (`folder/subfolder/file.md`)
217
+ 4. **No special characters**: Avoid `\\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`
218
+
219
+ ## Valid path examples:
220
+ - `notes/my-note.md`
221
+ - `projects/2025/meeting-notes.md`
222
+ - `archive/old-projects/legacy-note.md`
223
+
224
+ ## Try again with:
225
+ ```
226
+ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
227
+ ```"""
228
+
229
+ # Permission/access errors
230
+ if (
231
+ "permission" in error_message.lower()
232
+ or "access" in error_message.lower()
233
+ or "forbidden" in error_message.lower()
234
+ ):
235
+ return f"""# Move Failed - Permission Error
236
+
237
+ You don't have permission to move '{identifier}': {error_message}
238
+
239
+ ## How to resolve:
240
+ 1. **Check file permissions**: Ensure you have write access to both source and destination
241
+ 2. **Verify project access**: Make sure you have edit permissions for this project
242
+ 3. **Check file locks**: The file might be open in another application
243
+
244
+ ## Alternative actions:
245
+ - List available projects: `list_memory_projects()`
246
+ - Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
247
+
248
+ # Source file not found errors
249
+ if "source" in error_message.lower() and (
250
+ "not found" in error_message.lower() or "missing" in error_message.lower()
251
+ ):
252
+ return f"""# Move Failed - Source File Missing
253
+
254
+ The source file for '{identifier}' was not found on disk: {error_message}
255
+
256
+ This usually means the database and filesystem are out of sync.
257
+
258
+ ## How to resolve:
259
+ 1. **Check if note exists in database**: `read_note("{identifier}")`
260
+ 2. **Run sync operation**: The file might need to be re-synced
261
+ 3. **Recreate the file**: If data exists in database, recreate the physical file
262
+
263
+ ## Troubleshooting steps:
264
+ ```
265
+ # Check if note exists in Basic Memory
266
+ read_note("{identifier}")
267
+
268
+ # If it exists, the file is missing on disk - send a message to support@basicmachines.co
269
+ # If it doesn't exist, use search to find the correct identifier
270
+ search_notes("{identifier}")
271
+ ```"""
272
+
273
+ # Server/filesystem errors
274
+ if (
275
+ "server error" in error_message.lower()
276
+ or "filesystem" in error_message.lower()
277
+ or "disk" in error_message.lower()
278
+ ):
279
+ return f"""# Move Failed - System Error
280
+
281
+ A system error occurred while moving '{identifier}': {error_message}
282
+
283
+ ## Immediate steps:
284
+ 1. **Try again**: The error might be temporary
285
+ 2. **Check disk space**: Ensure adequate storage is available
286
+ 3. **Verify filesystem permissions**: Check if the destination directory is writable
287
+
288
+ ## Alternative approaches:
289
+ - Copy content to new location: Use `read_note("{identifier}")` then `write_note()`
290
+ - Use a different destination folder that you know works
291
+ - Send a message to support@basicmachines.co if the problem persists
292
+
293
+ ## Backup approach:
294
+ ```
295
+ # Read current content
296
+ content = read_note("{identifier}")
297
+
298
+ # Create new note at desired location
299
+ write_note("New Note Title", content, "{destination_path.split("/")[0] if "/" in destination_path else "notes"}")
300
+
301
+ # Then delete original if successful
302
+ delete_note("{identifier}")
303
+ ```"""
304
+
305
+ # Generic fallback
306
+ return f"""# Move Failed
307
+
308
+ Error moving '{identifier}' to '{destination_path}': {error_message}
309
+
310
+ ## General troubleshooting:
311
+ 1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
312
+ 2. **Check destination path**: Ensure it's a valid relative path with `.md` extension
313
+ 3. **Verify permissions**: Make sure you can edit files in this project
314
+ 4. **Try a simpler path**: Use a basic folder structure like `notes/filename.md`
315
+
316
+ ## Step-by-step approach:
317
+ ```
318
+ # 1. Confirm note exists
319
+ read_note("{identifier}")
320
+
321
+ # 2. Try a simple destination first
322
+ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
323
+
324
+ # 3. If that works, then try your original destination
325
+ ```
326
+
327
+ ## Alternative approach:
328
+ If moving continues to fail, you can copy the content manually:
329
+ ```
330
+ # Read current content
331
+ content = read_note("{identifier}")
332
+
333
+ # Create new note
334
+ write_note("Title", content, "target-folder")
335
+
336
+ # Delete original once confirmed
337
+ delete_note("{identifier}")
338
+ ```"""
339
+
340
+
341
+ @mcp.tool(
342
+ description="Move a note to a new location, updating database and maintaining links.",
343
+ )
344
+ async def move_note(
345
+ identifier: str,
346
+ destination_path: str,
347
+ project: Optional[str] = None,
348
+ context: Context | None = None,
349
+ ) -> str:
350
+ """Move a note to a new file location within the same project.
351
+
352
+ Moves a note from one location to another within the project, updating all
353
+ database references and maintaining semantic content. Uses stateless architecture -
354
+ project parameter optional with server resolution.
355
+
356
+ Args:
357
+ identifier: Exact entity identifier (title, permalink, or memory:// URL).
358
+ Must be an exact match - fuzzy matching is not supported for move operations.
359
+ Use search_notes() or read_note() first to find the correct identifier if uncertain.
360
+ destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
361
+ project: Project name to move within. Optional - server will resolve using hierarchy.
362
+ If unknown, use list_memory_projects() to discover available projects.
363
+ context: Optional FastMCP context for performance caching.
364
+
365
+ Returns:
366
+ Success message with move details and project information.
367
+
368
+ Examples:
369
+ # Move to new folder (exact title match)
370
+ move_note("My Note", "work/notes/my-note.md")
371
+
372
+ # Move by exact permalink
373
+ move_note("my-note-permalink", "archive/old-notes/my-note.md")
374
+
375
+ # Move with complex path structure
376
+ move_note("experiments/ml-results", "archive/2025/ml-experiments.md")
377
+
378
+ # Explicit project specification
379
+ move_note("My Note", "work/notes/my-note.md", project="work-project")
380
+
381
+ # If uncertain about identifier, search first:
382
+ # search_notes("my note") # Find available notes
383
+ # move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
384
+
385
+ Raises:
386
+ ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid
387
+
388
+ Note:
389
+ This operation moves notes within the specified project only. Moving notes
390
+ between different projects is not currently supported.
391
+
392
+ The move operation:
393
+ - Updates the entity's file_path in the database
394
+ - Moves the physical file on the filesystem
395
+ - Optionally updates permalinks if configured
396
+ - Re-indexes the entity for search
397
+ - Maintains all observations and relations
398
+ """
399
+ track_mcp_tool("move_note")
400
+ async with get_client() as client:
401
+ logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
402
+
403
+ active_project = await get_active_project(client, project, context)
404
+
405
+ # Validate destination path to prevent path traversal attacks
406
+ project_path = active_project.home
407
+ if not validate_project_path(destination_path, project_path):
408
+ logger.warning(
409
+ "Attempted path traversal attack blocked",
410
+ destination_path=destination_path,
411
+ project=active_project.name,
412
+ )
413
+ return f"""# Move Failed - Security Validation Error
414
+
415
+ The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
416
+
417
+ ## Valid path examples:
418
+ - `notes/my-file.md`
419
+ - `projects/2025/meeting-notes.md`
420
+ - `archive/old-notes.md`
421
+
422
+ ## Try again with a safe path:
423
+ ```
424
+ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
425
+ ```"""
426
+
427
+ # Check for potential cross-project move attempts
428
+ cross_project_error = await _detect_cross_project_move_attempt(
429
+ client, identifier, destination_path, active_project.name
430
+ )
431
+ if cross_project_error:
432
+ logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
433
+ return cross_project_error
434
+
435
+ # Get the source entity information for extension validation
436
+ source_ext = "md" # Default to .md if we can't determine source extension
437
+ try:
438
+ # Resolve identifier to entity ID
439
+ entity_id = await resolve_entity_id(client, active_project.id, identifier)
440
+ # Fetch source entity information to get the current file extension
441
+ url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
442
+ response = await call_get(client, url)
443
+ source_entity = EntityResponse.model_validate(response.json())
444
+ if "." in source_entity.file_path:
445
+ source_ext = source_entity.file_path.split(".")[-1]
446
+ except Exception as e:
447
+ # If we can't fetch the source entity, default to .md extension
448
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
449
+
450
+ # Validate that destination path includes a file extension
451
+ if "." not in destination_path or not destination_path.split(".")[-1]:
452
+ logger.warning(f"Move failed - no file extension provided: {destination_path}")
453
+ return dedent(f"""
454
+ # Move Failed - File Extension Required
455
+
456
+ The destination path '{destination_path}' must include a file extension (e.g., '.md').
457
+
458
+ ## Valid examples:
459
+ - `notes/my-note.md`
460
+ - `projects/meeting-2025.txt`
461
+ - `archive/old-program.sh`
462
+
463
+ ## Try again with extension:
464
+ ```
465
+ move_note("{identifier}", "{destination_path}.{source_ext}")
466
+ ```
467
+
468
+ All examples in Basic Memory expect file extensions to be explicitly provided.
469
+ """).strip()
470
+
471
+ # Get the source entity to check its file extension
472
+ try:
473
+ # Resolve identifier to entity ID (might already be cached from above)
474
+ entity_id = await resolve_entity_id(client, active_project.id, identifier)
475
+ # Fetch source entity information
476
+ url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}"
477
+ response = await call_get(client, url)
478
+ source_entity = EntityResponse.model_validate(response.json())
479
+
480
+ # Extract file extensions
481
+ source_ext = (
482
+ source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
483
+ )
484
+ dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
485
+
486
+ # Check if extensions match
487
+ if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
488
+ logger.warning(
489
+ f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
490
+ )
491
+ return dedent(f"""
492
+ # Move Failed - File Extension Mismatch
493
+
494
+ The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
495
+
496
+ To preserve file type consistency, the destination must have the same extension as the source.
497
+
498
+ ## Source file:
499
+ - Path: `{source_entity.file_path}`
500
+ - Extension: `.{source_ext}`
501
+
502
+ ## Try again with matching extension:
503
+ ```
504
+ move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
505
+ ```
506
+ """).strip()
507
+ except Exception as e:
508
+ # If we can't fetch the source entity, log it but continue
509
+ # This might happen if the identifier is not yet resolved
510
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
511
+
512
+ try:
513
+ # Resolve identifier to entity ID for the move operation
514
+ entity_id = await resolve_entity_id(client, active_project.id, identifier)
515
+
516
+ # Prepare move request (v2 API only needs destination_path)
517
+ move_data = {
518
+ "destination_path": destination_path,
519
+ }
520
+
521
+ # Call the v2 move API endpoint (PUT method, entity_id in URL)
522
+ url = f"/v2/projects/{active_project.id}/knowledge/entities/{entity_id}/move"
523
+ response = await call_put(client, url, json=move_data)
524
+ result = EntityResponse.model_validate(response.json())
525
+
526
+ # Build success message
527
+ result_lines = [
528
+ "✅ Note moved successfully",
529
+ "",
530
+ f"📁 **{identifier}** → **{result.file_path}**",
531
+ f"🔗 Permalink: {result.permalink}",
532
+ "📊 Database and search index updated",
533
+ "",
534
+ f"<!-- Project: {active_project.name} -->",
535
+ ]
536
+
537
+ # Log the operation
538
+ logger.info(
539
+ "Move note completed",
540
+ identifier=identifier,
541
+ destination_path=destination_path,
542
+ project=active_project.name,
543
+ status_code=response.status_code,
544
+ )
545
+
546
+ return "\n".join(result_lines)
547
+
548
+ except Exception as e:
549
+ logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
550
+ # Return formatted error message for better user experience
551
+ return _format_move_error_response(str(e), identifier, destination_path)