basic-memory 0.14.4__py3-none-any.whl → 0.15.0__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 (82) 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/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/cli/app.py +9 -28
  7. basic_memory/cli/auth.py +277 -0
  8. basic_memory/cli/commands/cloud/__init__.py +5 -0
  9. basic_memory/cli/commands/cloud/api_client.py +112 -0
  10. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  11. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  12. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  13. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  14. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  15. basic_memory/cli/commands/command_utils.py +60 -0
  16. basic_memory/cli/commands/import_memory_json.py +0 -4
  17. basic_memory/cli/commands/mcp.py +16 -4
  18. basic_memory/cli/commands/project.py +139 -142
  19. basic_memory/cli/commands/status.py +34 -22
  20. basic_memory/cli/commands/sync.py +45 -228
  21. basic_memory/cli/commands/tool.py +87 -16
  22. basic_memory/cli/main.py +1 -0
  23. basic_memory/config.py +76 -12
  24. basic_memory/db.py +104 -3
  25. basic_memory/deps.py +20 -3
  26. basic_memory/file_utils.py +37 -13
  27. basic_memory/ignore_utils.py +295 -0
  28. basic_memory/markdown/plugins.py +9 -7
  29. basic_memory/mcp/async_client.py +22 -10
  30. basic_memory/mcp/project_context.py +141 -0
  31. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  32. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  33. basic_memory/mcp/prompts/recent_activity.py +116 -32
  34. basic_memory/mcp/prompts/search.py +1 -1
  35. basic_memory/mcp/prompts/utils.py +11 -4
  36. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  37. basic_memory/mcp/resources/project_info.py +20 -6
  38. basic_memory/mcp/server.py +0 -37
  39. basic_memory/mcp/tools/__init__.py +5 -6
  40. basic_memory/mcp/tools/build_context.py +29 -19
  41. basic_memory/mcp/tools/canvas.py +19 -8
  42. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  43. basic_memory/mcp/tools/delete_note.py +67 -34
  44. basic_memory/mcp/tools/edit_note.py +55 -39
  45. basic_memory/mcp/tools/headers.py +44 -0
  46. basic_memory/mcp/tools/list_directory.py +18 -8
  47. basic_memory/mcp/tools/move_note.py +119 -41
  48. basic_memory/mcp/tools/project_management.py +61 -228
  49. basic_memory/mcp/tools/read_content.py +28 -12
  50. basic_memory/mcp/tools/read_note.py +83 -46
  51. basic_memory/mcp/tools/recent_activity.py +441 -42
  52. basic_memory/mcp/tools/search.py +82 -70
  53. basic_memory/mcp/tools/sync_status.py +5 -4
  54. basic_memory/mcp/tools/utils.py +19 -0
  55. basic_memory/mcp/tools/view_note.py +31 -6
  56. basic_memory/mcp/tools/write_note.py +65 -14
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +29 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +2 -2
  62. basic_memory/repository/search_repository.py +4 -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/entity_service.py +75 -45
  71. basic_memory/services/initialization.py +30 -11
  72. basic_memory/services/project_service.py +13 -23
  73. basic_memory/sync/sync_service.py +145 -21
  74. basic_memory/sync/watch_service.py +101 -40
  75. basic_memory/utils.py +14 -4
  76. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/METADATA +7 -6
  77. basic_memory-0.15.0.dist-info/RECORD +147 -0
  78. basic_memory/mcp/project_session.py +0 -120
  79. basic_memory-0.14.4.dist-info/RECORD +0 -133
  80. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  81. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.0.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
8
  from basic_memory.mcp.async_client import client
8
- from basic_memory.mcp.project_session import get_active_project
9
+ from basic_memory.mcp.project_context import get_active_project, add_project_metadata
9
10
  from basic_memory.mcp.server import mcp
10
11
  from basic_memory.mcp.tools.utils import call_patch
11
12
  from basic_memory.schemas import EntityResponse
@@ -17,6 +18,7 @@ def _format_error_response(
17
18
  identifier: str,
18
19
  find_text: Optional[str] = None,
19
20
  expected_replacements: int = 1,
21
+ project: Optional[str] = None,
20
22
  ) -> str:
21
23
  """Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
22
24
 
@@ -27,14 +29,14 @@ def _format_error_response(
27
29
  The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
28
30
 
29
31
  ## Suggestions to try:
30
- 1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
32
+ 1. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
31
33
  2. **Try different exact identifier formats**:
32
34
  - If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
33
35
  - If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
34
- - Use `read_note()` first to verify the note exists and get the exact identifier
36
+ - Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
35
37
 
36
38
  ## Alternative approach:
37
- Use `write_note()` to create the note first, then edit it."""
39
+ Use `write_note("{project or "project-name"}", "title", "content", "folder")` to create the note first, then edit it."""
38
40
 
39
41
  # Find/replace specific errors
40
42
  if operation == "find_replace":
@@ -44,7 +46,7 @@ Use `write_note()` to create the note first, then edit it."""
44
46
  The text '{find_text}' was not found in the note '{identifier}'.
45
47
 
46
48
  ## Suggestions to try:
47
- 1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
49
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the current content
48
50
  2. **Check for exact matches**: The search is case-sensitive and must match exactly
49
51
  3. **Try a broader search**: Search for just part of the text you want to replace
50
52
  4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
@@ -65,13 +67,13 @@ The text '{find_text}' was not found in the note '{identifier}'.
65
67
  Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
66
68
 
67
69
  ## How to fix:
68
- 1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
70
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see how many times '{find_text}' appears
69
71
  2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
70
72
  3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
71
73
 
72
74
  ## Example:
73
75
  ```
74
- edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
76
+ edit_note("{project or "project-name"}", "{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
75
77
  ```"""
76
78
 
77
79
  # Section replacement errors
@@ -81,7 +83,7 @@ edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", e
81
83
  Multiple sections found with the same header in note '{identifier}'.
82
84
 
83
85
  ## How to fix:
84
- 1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
86
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the document structure
85
87
  2. **Make headers unique**: Add more specific text to distinguish sections
86
88
  3. **Use append instead**: Add content at the end rather than replacing a specific section
87
89
 
@@ -97,14 +99,14 @@ Use `find_replace` to update specific text within the duplicate sections."""
97
99
  There was a problem with the edit request to note '{identifier}': {error_message}.
98
100
 
99
101
  ## Common causes and fixes:
100
- 1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
102
+ 1. **Note doesn't exist**: Use `search_notes("{project or "project-name"}", "query")` or `read_note("{project or "project-name"}", "{identifier}")` to verify the note exists
101
103
  2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
102
104
  3. **Empty or invalid content**: Check that your content is properly formatted
103
105
  4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
104
106
 
105
107
  ## Troubleshooting steps:
106
- 1. Verify the note exists: `read_note("{identifier}")`
107
- 2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
108
+ 1. Verify the note exists: `read_note("{project or "project-name"}", "{identifier}")`
109
+ 2. If not found, search for it: `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")`
108
110
  3. Try again with the correct identifier from the search results"""
109
111
 
110
112
  # Fallback for other errors
@@ -113,14 +115,14 @@ There was a problem with the edit request to note '{identifier}': {error_message
113
115
  Error editing note '{identifier}': {error_message}
114
116
 
115
117
  ## General troubleshooting:
116
- 1. **Verify the note exists**: Use `read_note("{identifier}")` to check
118
+ 1. **Verify the note exists**: Use `read_note("{project or "project-name"}", "{identifier}")` to check
117
119
  2. **Check your parameters**: Ensure all required parameters are provided correctly
118
- 3. **Read the note content first**: Use `read_note()` to understand the current structure
120
+ 3. **Read the note content first**: Use `read_note("{project or "project-name"}", "{identifier}")` to understand the current structure
119
121
  4. **Try a simpler operation**: Start with `append` if other operations fail
120
122
 
121
123
  ## Need help?
122
- - Use `search_notes()` to find notes
123
- - Use `read_note()` to examine content before editing
124
+ - Use `search_notes("{project or "project-name"}", "query")` to find notes
125
+ - Use `read_note("{project or "project-name"}", "identifier")` to examine content before editing
124
126
  - Check that identifiers, section headers, and find_text match exactly"""
125
127
 
126
128
 
@@ -131,15 +133,19 @@ async def edit_note(
131
133
  identifier: str,
132
134
  operation: str,
133
135
  content: str,
136
+ project: Optional[str] = None,
134
137
  section: Optional[str] = None,
135
138
  find_text: Optional[str] = None,
136
139
  expected_replacements: int = 1,
137
- project: Optional[str] = None,
140
+ context: Context | None = None,
138
141
  ) -> str:
139
142
  """Edit an existing markdown note in the knowledge base.
140
143
 
141
- This tool allows you to make targeted changes to existing notes without rewriting the entire content.
142
- It supports various operations for different editing scenarios.
144
+ Makes targeted changes to existing notes without rewriting the entire content.
145
+
146
+ Project Resolution:
147
+ Server resolves projects in this order: Single Project Mode → project parameter → default project.
148
+ If project unknown, use list_memory_projects() or recent_activity() first.
143
149
 
144
150
  Args:
145
151
  identifier: The exact title, permalink, or memory:// URL of the note to edit.
@@ -151,56 +157,64 @@ async def edit_note(
151
157
  - "find_replace": Replace occurrences of find_text with content
152
158
  - "replace_section": Replace content under a specific markdown header
153
159
  content: The content to add or use for replacement
160
+ project: Project name to edit in. Optional - server will resolve using hierarchy.
161
+ If unknown, use list_memory_projects() to discover available projects.
154
162
  section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
155
163
  find_text: For find_replace operation - the text to find and replace
156
164
  expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
157
- project: Optional project name to delete from. If not provided, uses current active project.
165
+ context: Optional FastMCP context for performance caching.
158
166
 
159
167
  Returns:
160
- A markdown formatted summary of the edit operation and resulting semantic content
168
+ A markdown formatted summary of the edit operation and resulting semantic content,
169
+ including operation details, file path, observations, relations, and project metadata.
161
170
 
162
171
  Examples:
163
172
  # Add new content to end of note
164
- edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
173
+ edit_note("my-project", "project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
165
174
 
166
175
  # Add timestamp at beginning (frontmatter-aware)
167
- edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
176
+ edit_note("work-docs", "meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
168
177
 
169
178
  # Update version number (single occurrence)
170
- edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
179
+ edit_note("api-project", "config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
171
180
 
172
181
  # Update version in multiple places with validation
173
- edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
182
+ edit_note("docs-project", "api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
174
183
 
175
184
  # Replace text that appears multiple times - validate count first
176
- edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
185
+ edit_note("team-docs", "docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
177
186
 
178
187
  # Replace implementation section
179
- edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
188
+ edit_note("specs", "api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
180
189
 
181
190
  # Replace subsection with more specific header
182
- edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
191
+ edit_note("docs", "docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
183
192
 
184
193
  # Using different identifier formats (must be exact matches)
185
- edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # exact title
186
- edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
187
- edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # exact folder/title
194
+ edit_note("work-project", "Meeting Notes", "append", "\\n- Follow up on action items") # exact title
195
+ edit_note("work-project", "docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
188
196
 
189
197
  # If uncertain about identifier, search first:
190
- # search_notes("meeting") # Find available notes
191
- # edit_note("docs/meeting-notes-2025", "append", "content") # Use exact result
198
+ # search_notes("work-project", "meeting") # Find available notes
199
+ # edit_note("work-project", "docs/meeting-notes-2025", "append", "content") # Use exact result
192
200
 
193
201
  # Add new section to document
194
- edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
202
+ edit_note("planning", "project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
195
203
 
196
204
  # Update status across document (expecting exactly 2 occurrences)
197
- edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
205
+ edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
198
206
 
199
- # Replace text in a file, specifying project name
200
- edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
207
+ Raises:
208
+ HTTPError: If project doesn't exist or is inaccessible
209
+ ValueError: If operation is invalid or required parameters are missing
210
+ SecurityError: If identifier attempts path traversal
201
211
 
212
+ Note:
213
+ Edit operations require exact identifier matches. If unsure, use read_note() or
214
+ search_notes() first to find the correct identifier. The tool provides detailed
215
+ error messages with suggestions if operations fail.
202
216
  """
203
- active_project = get_active_project(project)
217
+ active_project = await get_active_project(client, project, context)
204
218
  project_url = active_project.project_url
205
219
 
206
220
  logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
@@ -288,16 +302,18 @@ async def edit_note(
288
302
  "MCP tool response",
289
303
  tool="edit_note",
290
304
  operation=operation,
305
+ project=active_project.name,
291
306
  permalink=result.permalink,
292
307
  observations_count=len(result.observations),
293
308
  relations_count=len(result.relations),
294
309
  status_code=response.status_code,
295
310
  )
296
311
 
297
- return "\n".join(summary)
312
+ result = "\n".join(summary)
313
+ return add_project_metadata(result, active_project.name)
298
314
 
299
315
  except Exception as e:
300
316
  logger.error(f"Error editing note: {e}")
301
317
  return _format_error_response(
302
- str(e), operation, identifier, find_text, expected_replacements
318
+ str(e), operation, identifier, find_text, expected_replacements, active_project.name
303
319
  )
@@ -0,0 +1,44 @@
1
+ from httpx._types import (
2
+ HeaderTypes,
3
+ )
4
+ from loguru import logger
5
+ from fastmcp.server.dependencies import get_http_headers
6
+
7
+
8
+ def inject_auth_header(headers: HeaderTypes | None = None) -> HeaderTypes:
9
+ """
10
+ Inject JWT token from FastMCP context into headers if available.
11
+
12
+ Args:
13
+ headers: Existing headers dict or None
14
+
15
+ Returns:
16
+ Headers dict with Authorization header added if JWT is available
17
+ """
18
+ # Start with existing headers or empty dict
19
+ if headers is None:
20
+ headers = {}
21
+ elif not isinstance(headers, dict):
22
+ # Convert other header types to dict
23
+ headers = dict(headers) # type: ignore
24
+ else:
25
+ # Make a copy to avoid modifying the original
26
+ headers = headers.copy()
27
+
28
+ http_headers = get_http_headers()
29
+
30
+ # Log only non-sensitive header keys for debugging
31
+ if logger.opt(lazy=True).debug:
32
+ sensitive_headers = {"authorization", "cookie", "x-api-key", "x-auth-token", "api-key"}
33
+ safe_headers = {k for k in http_headers.keys() if k.lower() not in sensitive_headers}
34
+ logger.debug(f"HTTP headers present: {list(safe_headers)}")
35
+
36
+ authorization = http_headers.get("Authorization") or http_headers.get("authorization")
37
+ if authorization:
38
+ headers["Authorization"] = authorization # type: ignore
39
+ # Log only that auth was injected, not the token value
40
+ logger.debug("Injected authorization header into request")
41
+ else:
42
+ logger.debug("No authorization header found in request")
43
+
44
+ return headers
@@ -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
8
  from basic_memory.mcp.async_client import client
8
- from basic_memory.mcp.project_session import get_active_project
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,10 +57,13 @@ 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)
66
+ active_project = await get_active_project(client, project, context)
59
67
  project_url = active_project.project_url
60
68
 
61
69
  # Prepare query parameters
@@ -66,7 +74,9 @@ async def list_directory(
66
74
  if file_name_glob:
67
75
  params["file_name_glob"] = file_name_glob
68
76
 
69
- logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
77
+ logger.debug(
78
+ f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
79
+ )
70
80
 
71
81
  # Call the API endpoint
72
82
  response = await call_get(
@@ -4,11 +4,12 @@ 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
9
  from basic_memory.mcp.async_client import 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
@@ -64,42 +65,37 @@ def _format_cross_project_error_response(
64
65
  """Format error response for detected cross-project move attempts."""
65
66
  return dedent(f"""
66
67
  # Move Failed - Cross-Project Move Not Supported
67
-
68
+
68
69
  Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
69
-
70
+
70
71
  **Current project:** {current_project}
71
72
  **Target project:** {target_project}
72
-
73
+
73
74
  ## Cross-project moves are not supported directly
74
-
75
+
75
76
  Notes can only be moved within the same project. To move content between projects, use this workflow:
76
-
77
+
77
78
  ### Recommended approach:
78
79
  ```
79
80
  # 1. Read the note content from current project
80
81
  read_note("{identifier}")
81
82
 
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}")
83
+ # 2. Create the note in the target project
84
+ write_note("Note Title", "content from step 1", "target-folder", project="{target_project}")
85
+
86
+ # 3. Delete the original note if desired
87
+ delete_note("{identifier}", project="{current_project}")
90
88
 
91
- # 5. Delete the original note if desired
92
- delete_note("{identifier}")
93
89
  ```
94
-
90
+
95
91
  ### Alternative: Stay in current project
96
92
  If you want to move the note within the **{current_project}** project only:
97
93
  ```
98
94
  move_note("{identifier}", "new-folder/new-name.md")
99
95
  ```
100
-
96
+
101
97
  ## Available projects:
102
- Use `list_projects()` to see all available projects and `switch_project("project-name")` to change projects.
98
+ Use `list_memory_projects()` to see all available projects.
103
99
  """).strip()
104
100
 
105
101
 
@@ -130,20 +126,16 @@ def _format_potential_cross_project_guidance(
130
126
  # 1. Read the content
131
127
  read_note("{identifier}")
132
128
 
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}")
129
+ # 2. Create note in target project
130
+ write_note("Title", "content", "folder", project="target-project-name")
131
+
132
+ # 3. Delete original if desired
133
+ delete_note("{identifier}", project="{current_project}")
142
134
  ```
143
135
 
144
136
  ### To see all projects:
145
137
  ```
146
- list_projects()
138
+ list_memory_projects()
147
139
  ```
148
140
  """).strip()
149
141
 
@@ -171,7 +163,7 @@ def _format_move_error_response(error_message: str, identifier: str, destination
171
163
  - If you used a title, try the exact permalink format: "{permalink_format}"
172
164
  - Use `read_note()` first to verify the note exists and get the exact identifier
173
165
 
174
- 3. **Check current project**: Use `get_current_project()` to verify you're in the right project
166
+ 3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
175
167
  4. **List available notes**: Use `list_directory("/")` to see what notes exist
176
168
 
177
169
  ## Before trying again:
@@ -248,9 +240,8 @@ You don't have permission to move '{identifier}': {error_message}
248
240
  3. **Check file locks**: The file might be open in another application
249
241
 
250
242
  ## 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"""
243
+ - List available projects: `list_memory_projects()`
244
+ - Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
254
245
 
255
246
  # Source file not found errors
256
247
  if "source" in error_message.lower() and (
@@ -352,18 +343,25 @@ async def move_note(
352
343
  identifier: str,
353
344
  destination_path: str,
354
345
  project: Optional[str] = None,
346
+ context: Context | None = None,
355
347
  ) -> str:
356
348
  """Move a note to a new file location within the same project.
357
349
 
350
+ Moves a note from one location to another within the project, updating all
351
+ database references and maintaining semantic content. Uses stateless architecture -
352
+ project parameter optional with server resolution.
353
+
358
354
  Args:
359
355
  identifier: Exact entity identifier (title, permalink, or memory:// URL).
360
356
  Must be an exact match - fuzzy matching is not supported for move operations.
361
357
  Use search_notes() or read_note() first to find the correct identifier if uncertain.
362
358
  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)
359
+ project: Project name to move within. Optional - server will resolve using hierarchy.
360
+ If unknown, use list_memory_projects() to discover available projects.
361
+ context: Optional FastMCP context for performance caching.
364
362
 
365
363
  Returns:
366
- Success message with move details
364
+ Success message with move details and project information.
367
365
 
368
366
  Examples:
369
367
  # Move to new folder (exact title match)
@@ -372,15 +370,22 @@ async def move_note(
372
370
  # Move by exact permalink
373
371
  move_note("my-note-permalink", "archive/old-notes/my-note.md")
374
372
 
375
- # Specify project with exact identifier
376
- move_note("My Note", "archive/my-note.md", project="work-project")
373
+ # Move with complex path structure
374
+ move_note("experiments/ml-results", "archive/2025/ml-experiments.md")
375
+
376
+ # Explicit project specification
377
+ move_note("My Note", "work/notes/my-note.md", project="work-project")
377
378
 
378
379
  # If uncertain about identifier, search first:
379
380
  # search_notes("my note") # Find available notes
380
381
  # move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
381
382
 
382
- Note: This operation moves notes within the specified project only. Moving notes
383
- between different projects is not currently supported.
383
+ Raises:
384
+ ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid
385
+
386
+ Note:
387
+ This operation moves notes within the specified project only. Moving notes
388
+ between different projects is not currently supported.
384
389
 
385
390
  The move operation:
386
391
  - Updates the entity's file_path in the database
@@ -389,9 +394,9 @@ async def move_note(
389
394
  - Re-indexes the entity for search
390
395
  - Maintains all observations and relations
391
396
  """
392
- logger.debug(f"Moving note: {identifier} to {destination_path}")
397
+ logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
393
398
 
394
- active_project = get_active_project(project)
399
+ active_project = await get_active_project(client, project, context)
395
400
  project_url = active_project.project_url
396
401
 
397
402
  # Validate destination path to prevent path traversal attacks
@@ -424,6 +429,79 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
424
429
  logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
425
430
  return cross_project_error
426
431
 
432
+ # Get the source entity information for extension validation
433
+ source_ext = "md" # Default to .md if we can't determine source extension
434
+ try:
435
+ # Fetch source entity information to get the current file extension
436
+ url = f"{project_url}/knowledge/entities/{identifier}"
437
+ response = await call_get(client, url)
438
+ source_entity = EntityResponse.model_validate(response.json())
439
+ if "." in source_entity.file_path:
440
+ source_ext = source_entity.file_path.split(".")[-1]
441
+ except Exception as e:
442
+ # If we can't fetch the source entity, default to .md extension
443
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
444
+
445
+ # Validate that destination path includes a file extension
446
+ if "." not in destination_path or not destination_path.split(".")[-1]:
447
+ logger.warning(f"Move failed - no file extension provided: {destination_path}")
448
+ return dedent(f"""
449
+ # Move Failed - File Extension Required
450
+
451
+ The destination path '{destination_path}' must include a file extension (e.g., '.md').
452
+
453
+ ## Valid examples:
454
+ - `notes/my-note.md`
455
+ - `projects/meeting-2025.txt`
456
+ - `archive/old-program.sh`
457
+
458
+ ## Try again with extension:
459
+ ```
460
+ move_note("{identifier}", "{destination_path}.{source_ext}")
461
+ ```
462
+
463
+ All examples in Basic Memory expect file extensions to be explicitly provided.
464
+ """).strip()
465
+
466
+ # Get the source entity to check its file extension
467
+ try:
468
+ # Fetch source entity information
469
+ url = f"{project_url}/knowledge/entities/{identifier}"
470
+ response = await call_get(client, url)
471
+ source_entity = EntityResponse.model_validate(response.json())
472
+
473
+ # Extract file extensions
474
+ source_ext = (
475
+ source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
476
+ )
477
+ dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
478
+
479
+ # Check if extensions match
480
+ if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
481
+ logger.warning(
482
+ f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
483
+ )
484
+ return dedent(f"""
485
+ # Move Failed - File Extension Mismatch
486
+
487
+ The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
488
+
489
+ To preserve file type consistency, the destination must have the same extension as the source.
490
+
491
+ ## Source file:
492
+ - Path: `{source_entity.file_path}`
493
+ - Extension: `.{source_ext}`
494
+
495
+ ## Try again with matching extension:
496
+ ```
497
+ move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
498
+ ```
499
+ """).strip()
500
+ except Exception as e:
501
+ # If we can't fetch the source entity, log it but continue
502
+ # This might happen if the identifier is not yet resolved
503
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
504
+
427
505
  try:
428
506
  # Prepare move request
429
507
  move_data = {