basic-memory 0.14.4__py3-none-any.whl → 0.15.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 (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -3,9 +3,10 @@
3
3
  from typing import Optional
4
4
 
5
5
  from loguru import logger
6
+ from fastmcp import Context
6
7
 
7
- from basic_memory.mcp.async_client import client
8
- from basic_memory.mcp.project_session import get_active_project
8
+ from basic_memory.mcp.async_client import get_client
9
+ from basic_memory.mcp.project_context import get_active_project
9
10
  from basic_memory.mcp.server import mcp
10
11
  from basic_memory.mcp.tools.utils import call_get
11
12
 
@@ -18,6 +19,7 @@ async def list_directory(
18
19
  depth: int = 1,
19
20
  file_name_glob: Optional[str] = None,
20
21
  project: Optional[str] = None,
22
+ context: Context | None = None,
21
23
  ) -> str:
22
24
  """List directory contents from the knowledge base with optional filtering.
23
25
 
@@ -32,7 +34,10 @@ async def list_directory(
32
34
  Higher values show subdirectory contents recursively
33
35
  file_name_glob: Optional glob pattern for filtering file names
34
36
  Examples: "*.md", "*meeting*", "project_*"
35
- project: Optional project name to delete from. If not provided, uses current active project.
37
+ project: Project name to list directory from. Optional - server will resolve using hierarchy.
38
+ If unknown, use list_memory_projects() to discover available projects.
39
+ context: Optional FastMCP context for performance caching.
40
+
36
41
  Returns:
37
42
  Formatted listing of directory contents with file metadata
38
43
 
@@ -43,8 +48,8 @@ async def list_directory(
43
48
  # List specific folder
44
49
  list_directory(dir_name="/projects")
45
50
 
46
- # Find all Python files
47
- list_directory(file_name_glob="*.py")
51
+ # Find all markdown files
52
+ list_directory(file_name_glob="*.md")
48
53
 
49
54
  # Deep exploration of research folder
50
55
  list_directory(dir_name="/research", depth=3)
@@ -52,103 +57,111 @@ async def list_directory(
52
57
  # Find meeting notes in projects folder
53
58
  list_directory(dir_name="/projects", file_name_glob="*meeting*")
54
59
 
55
- # Find meeting notes in a specific project
56
- list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
60
+ # Explicit project specification
61
+ list_directory(project="work-docs", dir_name="/projects")
62
+
63
+ Raises:
64
+ ToolError: If project doesn't exist or directory path is invalid
57
65
  """
58
- active_project = get_active_project(project)
59
- project_url = active_project.project_url
60
-
61
- # Prepare query parameters
62
- params = {
63
- "dir_name": dir_name,
64
- "depth": str(depth),
65
- }
66
- if file_name_glob:
67
- params["file_name_glob"] = file_name_glob
68
-
69
- logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
70
-
71
- # Call the API endpoint
72
- response = await call_get(
73
- client,
74
- f"{project_url}/directory/list",
75
- params=params,
76
- )
77
-
78
- nodes = response.json()
79
-
80
- if not nodes:
81
- filter_desc = ""
66
+ async with get_client() as client:
67
+ active_project = await get_active_project(client, project, context)
68
+ project_url = active_project.project_url
69
+
70
+ # Prepare query parameters
71
+ params = {
72
+ "dir_name": dir_name,
73
+ "depth": str(depth),
74
+ }
82
75
  if file_name_glob:
83
- filter_desc = f" matching '{file_name_glob}'"
84
- return f"No files found in directory '{dir_name}'{filter_desc}"
85
-
86
- # Format the results
87
- output_lines = []
88
- if file_name_glob:
89
- output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
90
- else:
91
- output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
92
- output_lines.append("")
93
-
94
- # Group by type and sort
95
- directories = [n for n in nodes if n["type"] == "directory"]
96
- files = [n for n in nodes if n["type"] == "file"]
97
-
98
- # Sort by name
99
- directories.sort(key=lambda x: x["name"])
100
- files.sort(key=lambda x: x["name"])
101
-
102
- # Display directories first
103
- for node in directories:
104
- path_display = node["directory_path"]
105
- output_lines.append(f"📁 {node['name']:<30} {path_display}")
106
-
107
- # Add separator if we have both directories and files
108
- if directories and files:
109
- output_lines.append("")
76
+ params["file_name_glob"] = file_name_glob
77
+
78
+ logger.debug(
79
+ f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
80
+ )
110
81
 
111
- # Display files with metadata
112
- for node in files:
113
- path_display = node["directory_path"]
114
- title = node.get("title", "")
115
- updated = node.get("updated_at", "")
116
-
117
- # Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
118
- if path_display.startswith("/"):
119
- path_display = path_display[1:]
120
-
121
- # Format date if available
122
- date_str = ""
123
- if updated:
124
- try:
125
- from datetime import datetime
126
-
127
- dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
128
- date_str = dt.strftime("%Y-%m-%d")
129
- except Exception: # pragma: no cover
130
- date_str = updated[:10] if len(updated) >= 10 else ""
131
-
132
- # Create formatted line
133
- file_line = f"📄 {node['name']:<30} {path_display}"
134
- if title and title != node["name"]:
135
- file_line += f" | {title}"
136
- if date_str:
137
- file_line += f" | {date_str}"
138
-
139
- output_lines.append(file_line)
140
-
141
- # Add summary
142
- output_lines.append("")
143
- total_count = len(directories) + len(files)
144
- summary_parts = []
145
- if directories:
146
- summary_parts.append(
147
- f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
82
+ # Call the API endpoint
83
+ response = await call_get(
84
+ client,
85
+ f"{project_url}/directory/list",
86
+ params=params,
148
87
  )
149
- if files:
150
- summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
151
88
 
152
- output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
89
+ nodes = response.json()
153
90
 
154
- return "\n".join(output_lines)
91
+ if not nodes:
92
+ filter_desc = ""
93
+ if file_name_glob:
94
+ filter_desc = f" matching '{file_name_glob}'"
95
+ return f"No files found in directory '{dir_name}'{filter_desc}"
96
+
97
+ # Format the results
98
+ output_lines = []
99
+ if file_name_glob:
100
+ output_lines.append(
101
+ f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):"
102
+ )
103
+ else:
104
+ output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
105
+ output_lines.append("")
106
+
107
+ # Group by type and sort
108
+ directories = [n for n in nodes if n["type"] == "directory"]
109
+ files = [n for n in nodes if n["type"] == "file"]
110
+
111
+ # Sort by name
112
+ directories.sort(key=lambda x: x["name"])
113
+ files.sort(key=lambda x: x["name"])
114
+
115
+ # Display directories first
116
+ for node in directories:
117
+ path_display = node["directory_path"]
118
+ output_lines.append(f"📁 {node['name']:<30} {path_display}")
119
+
120
+ # Add separator if we have both directories and files
121
+ if directories and files:
122
+ output_lines.append("")
123
+
124
+ # Display files with metadata
125
+ for node in files:
126
+ path_display = node["directory_path"]
127
+ title = node.get("title", "")
128
+ updated = node.get("updated_at", "")
129
+
130
+ # Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
131
+ if path_display.startswith("/"):
132
+ path_display = path_display[1:]
133
+
134
+ # Format date if available
135
+ date_str = ""
136
+ if updated:
137
+ try:
138
+ from datetime import datetime
139
+
140
+ dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
141
+ date_str = dt.strftime("%Y-%m-%d")
142
+ except Exception: # pragma: no cover
143
+ date_str = updated[:10] if len(updated) >= 10 else ""
144
+
145
+ # Create formatted line
146
+ file_line = f"📄 {node['name']:<30} {path_display}"
147
+ if title and title != node["name"]:
148
+ file_line += f" | {title}"
149
+ if date_str:
150
+ file_line += f" | {date_str}"
151
+
152
+ output_lines.append(file_line)
153
+
154
+ # Add summary
155
+ output_lines.append("")
156
+ total_count = len(directories) + len(files)
157
+ summary_parts = []
158
+ if directories:
159
+ summary_parts.append(
160
+ f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
161
+ )
162
+ if files:
163
+ summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
164
+
165
+ output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
166
+
167
+ return "\n".join(output_lines)
@@ -4,22 +4,24 @@ from textwrap import dedent
4
4
  from typing import Optional
5
5
 
6
6
  from loguru import logger
7
+ from fastmcp import Context
7
8
 
8
- from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.async_client import get_client
9
10
  from basic_memory.mcp.server import mcp
10
11
  from basic_memory.mcp.tools.utils import call_post, call_get
11
- from basic_memory.mcp.project_session import get_active_project
12
+ from basic_memory.mcp.project_context import get_active_project
12
13
  from basic_memory.schemas import EntityResponse
13
14
  from basic_memory.schemas.project_info import ProjectList
14
15
  from basic_memory.utils import validate_project_path
15
16
 
16
17
 
17
18
  async def _detect_cross_project_move_attempt(
18
- identifier: str, destination_path: str, current_project: str
19
+ client, identifier: str, destination_path: str, current_project: str
19
20
  ) -> Optional[str]:
20
21
  """Detect potential cross-project move attempts and return guidance.
21
22
 
22
23
  Args:
24
+ client: The AsyncClient instance
23
25
  identifier: The note identifier being moved
24
26
  destination_path: The destination path
25
27
  current_project: The current active project
@@ -64,42 +66,37 @@ def _format_cross_project_error_response(
64
66
  """Format error response for detected cross-project move attempts."""
65
67
  return dedent(f"""
66
68
  # Move Failed - Cross-Project Move Not Supported
67
-
69
+
68
70
  Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
69
-
71
+
70
72
  **Current project:** {current_project}
71
73
  **Target project:** {target_project}
72
-
74
+
73
75
  ## Cross-project moves are not supported directly
74
-
76
+
75
77
  Notes can only be moved within the same project. To move content between projects, use this workflow:
76
-
78
+
77
79
  ### Recommended approach:
78
80
  ```
79
81
  # 1. Read the note content from current project
80
82
  read_note("{identifier}")
81
83
 
82
- # 2. Switch to the target project
83
- switch_project("{target_project}")
84
-
85
- # 3. Create the note in the target project
86
- write_note("Note Title", "content from step 1", "target-folder")
87
-
88
- # 4. Switch back to original project (optional)
89
- switch_project("{current_project}")
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}")
90
89
 
91
- # 5. Delete the original note if desired
92
- delete_note("{identifier}")
93
90
  ```
94
-
91
+
95
92
  ### Alternative: Stay in current project
96
93
  If you want to move the note within the **{current_project}** project only:
97
94
  ```
98
95
  move_note("{identifier}", "new-folder/new-name.md")
99
96
  ```
100
-
97
+
101
98
  ## Available projects:
102
- Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
99
+ Use `list_memory_projects()` to see all available projects.
103
100
  """).strip()
104
101
 
105
102
 
@@ -130,20 +127,16 @@ def _format_potential_cross_project_guidance(
130
127
  # 1. Read the content
131
128
  read_note("{identifier}")
132
129
 
133
- # 2. Switch to target project
134
- switch_project("target-project-name")
135
-
136
- # 3. Create note in target project
137
- write_note("Title", "content", "folder")
138
-
139
- # 4. Switch back and delete original if desired
140
- switch_project("{current_project}")
141
- delete_note("{identifier}")
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}")
142
135
  ```
143
136
 
144
137
  ### To see all projects:
145
138
  ```
146
- list_projects()
139
+ list_memory_projects()
147
140
  ```
148
141
  """).strip()
149
142
 
@@ -171,7 +164,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination
171
164
  - If you used a title, try the exact permalink format: "{permalink_format}"
172
165
  - Use `read_note()` first to verify the note exists and get the exact identifier
173
166
 
174
- 3. **Check current project**: Use `get_current_project()` to verify you're in the right project
167
+ 3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
175
168
  4. **List available notes**: Use `list_directory("/")` to see what notes exist
176
169
 
177
170
  ## Before trying again:
@@ -248,9 +241,8 @@ You don't have permission to move '{identifier}': {error_message}
248
241
  3. **Check file locks**: The file might be open in another application
249
242
 
250
243
  ## Alternative actions:
251
- - Check current project: `get_current_project()`
252
- - Switch projects if needed: `switch_project("project-name")`
253
- - Try copying content instead: `read_note("{identifier}")` then `write_note()` to new location"""
244
+ - List available projects: `list_memory_projects()`
245
+ - Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
254
246
 
255
247
  # Source file not found errors
256
248
  if "source" in error_message.lower() and (
@@ -352,18 +344,25 @@ async def move_note(
352
344
  identifier: str,
353
345
  destination_path: str,
354
346
  project: Optional[str] = None,
347
+ context: Context | None = None,
355
348
  ) -> str:
356
349
  """Move a note to a new file location within the same project.
357
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
+
358
355
  Args:
359
356
  identifier: Exact entity identifier (title, permalink, or memory:// URL).
360
357
  Must be an exact match - fuzzy matching is not supported for move operations.
361
358
  Use search_notes() or read_note() first to find the correct identifier if uncertain.
362
359
  destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
363
- project: Optional project name (defaults to current session project)
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.
364
363
 
365
364
  Returns:
366
- Success message with move details
365
+ Success message with move details and project information.
367
366
 
368
367
  Examples:
369
368
  # Move to new folder (exact title match)
@@ -372,15 +371,22 @@ async def move_note(
372
371
  # Move by exact permalink
373
372
  move_note("my-note-permalink", "archive/old-notes/my-note.md")
374
373
 
375
- # Specify project with exact identifier
376
- move_note("My Note", "archive/my-note.md", project="work-project")
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")
377
379
 
378
380
  # If uncertain about identifier, search first:
379
381
  # search_notes("my note") # Find available notes
380
382
  # move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
381
383
 
382
- Note: This operation moves notes within the specified project only. Moving notes
383
- between different projects is not currently supported.
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.
384
390
 
385
391
  The move operation:
386
392
  - Updates the entity's file_path in the database
@@ -389,20 +395,21 @@ async def move_note(
389
395
  - Re-indexes the entity for search
390
396
  - Maintains all observations and relations
391
397
  """
392
- logger.debug(f"Moving note: {identifier} to {destination_path}")
393
-
394
- active_project = get_active_project(project)
395
- project_url = active_project.project_url
396
-
397
- # Validate destination path to prevent path traversal attacks
398
- project_path = active_project.home
399
- if not validate_project_path(destination_path, project_path):
400
- logger.warning(
401
- "Attempted path traversal attack blocked",
402
- destination_path=destination_path,
403
- project=active_project.name,
404
- )
405
- return f"""# Move Failed - Security Validation Error
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
406
413
 
407
414
  The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
408
415
 
@@ -416,50 +423,123 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
416
423
  move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
417
424
  ```"""
418
425
 
419
- # Check for potential cross-project move attempts
420
- cross_project_error = await _detect_cross_project_move_attempt(
421
- identifier, destination_path, active_project.name
422
- )
423
- if cross_project_error:
424
- logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
425
- return cross_project_error
426
-
427
- try:
428
- # Prepare move request
429
- move_data = {
430
- "identifier": identifier,
431
- "destination_path": destination_path,
432
- "project": active_project.name,
433
- }
434
-
435
- # Call the move API endpoint
436
- url = f"{project_url}/knowledge/move"
437
- response = await call_post(client, url, json=move_data)
438
- result = EntityResponse.model_validate(response.json())
439
-
440
- # Build success message
441
- result_lines = [
442
- "✅ Note moved successfully",
443
- "",
444
- f"📁 **{identifier}** → **{result.file_path}**",
445
- f"🔗 Permalink: {result.permalink}",
446
- "📊 Database and search index updated",
447
- "",
448
- f"<!-- Project: {active_project.name} -->",
449
- ]
450
-
451
- # Log the operation
452
- logger.info(
453
- "Move note completed",
454
- identifier=identifier,
455
- destination_path=destination_path,
456
- project=active_project.name,
457
- status_code=response.status_code,
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
458
429
  )
459
-
460
- return "\n".join(result_lines)
461
-
462
- except Exception as e:
463
- logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
464
- # Return formatted error message for better user experience
465
- return _format_move_error_response(str(e), identifier, destination_path)
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)