basic-memory 0.7.0__py3-none-any.whl → 0.16.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.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

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