basic-memory 0.12.2__py3-none-any.whl → 0.13.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 (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,299 @@
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
+
8
+ from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.server import mcp
10
+ from basic_memory.mcp.tools.utils import call_post
11
+ from basic_memory.mcp.project_session import get_active_project
12
+ from basic_memory.schemas import EntityResponse
13
+
14
+
15
+ def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
16
+ """Format helpful error responses for move failures that guide users to successful moves."""
17
+
18
+ # Note not found errors
19
+ if "entity not found" in error_message.lower() or "not found" in error_message.lower():
20
+ search_term = identifier.split("/")[-1] if "/" in identifier else identifier
21
+ title_format = (
22
+ identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
23
+ )
24
+ permalink_format = identifier.lower().replace(" ", "-")
25
+
26
+ return dedent(f"""
27
+ # Move Failed - Note Not Found
28
+
29
+ The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching).
30
+
31
+ ## Suggestions to try:
32
+ 1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers
33
+ 2. **Try different exact identifier formats**:
34
+ - If you used a permalink like "folder/note-title", try the exact title: "{title_format}"
35
+ - If you used a title, try the exact permalink format: "{permalink_format}"
36
+ - Use `read_note()` first to verify the note exists and get the exact identifier
37
+
38
+ 3. **Check current project**: Use `get_current_project()` to verify you're in the right project
39
+ 4. **List available notes**: Use `list_directory("/")` to see what notes exist
40
+
41
+ ## Before trying again:
42
+ ```
43
+ # First, verify the note exists:
44
+ search_notes("{identifier}")
45
+
46
+ # Then use the exact identifier from search results:
47
+ move_note("correct-identifier-here", "{destination_path}")
48
+ ```
49
+ """).strip()
50
+
51
+ # Destination already exists errors
52
+ if "already exists" in error_message.lower() or "file exists" in error_message.lower():
53
+ return f"""# Move Failed - Destination Already Exists
54
+
55
+ Cannot move '{identifier}' to '{destination_path}' because a file already exists at that location.
56
+
57
+ ## How to resolve:
58
+ 1. **Choose a different destination**: Try a different filename or folder
59
+ - Add timestamp: `{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md`
60
+ - Use different folder: `archive/{destination_path}` or `backup/{destination_path}`
61
+
62
+ 2. **Check the existing file**: Use `read_note("{destination_path}")` to see what's already there
63
+ 3. **Remove or rename existing**: If safe to do so, move the existing file first
64
+
65
+ ## Try these alternatives:
66
+ ```
67
+ # Option 1: Add timestamp to make unique
68
+ move_note("{identifier}", "{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md")
69
+
70
+ # Option 2: Use archive folder
71
+ move_note("{identifier}", "archive/{destination_path}")
72
+
73
+ # Option 3: Check what's at destination first
74
+ read_note("{destination_path}")
75
+ ```"""
76
+
77
+ # Invalid path errors
78
+ if "invalid" in error_message.lower() and "path" in error_message.lower():
79
+ return f"""# Move Failed - Invalid Destination Path
80
+
81
+ The destination path '{destination_path}' is not valid: {error_message}
82
+
83
+ ## Path requirements:
84
+ 1. **Relative paths only**: Don't start with `/` (use `notes/file.md` not `/notes/file.md`)
85
+ 2. **Include file extension**: Add `.md` for markdown files
86
+ 3. **Use forward slashes**: For folder separators (`folder/subfolder/file.md`)
87
+ 4. **No special characters**: Avoid `\\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`
88
+
89
+ ## Valid path examples:
90
+ - `notes/my-note.md`
91
+ - `projects/2025/meeting-notes.md`
92
+ - `archive/old-projects/legacy-note.md`
93
+
94
+ ## Try again with:
95
+ ```
96
+ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
97
+ ```"""
98
+
99
+ # Permission/access errors
100
+ if (
101
+ "permission" in error_message.lower()
102
+ or "access" in error_message.lower()
103
+ or "forbidden" in error_message.lower()
104
+ ):
105
+ return f"""# Move Failed - Permission Error
106
+
107
+ You don't have permission to move '{identifier}': {error_message}
108
+
109
+ ## How to resolve:
110
+ 1. **Check file permissions**: Ensure you have write access to both source and destination
111
+ 2. **Verify project access**: Make sure you have edit permissions for this project
112
+ 3. **Check file locks**: The file might be open in another application
113
+
114
+ ## Alternative actions:
115
+ - Check current project: `get_current_project()`
116
+ - Switch projects if needed: `switch_project("project-name")`
117
+ - Try copying content instead: `read_note("{identifier}")` then `write_note()` to new location"""
118
+
119
+ # Source file not found errors
120
+ if "source" in error_message.lower() and (
121
+ "not found" in error_message.lower() or "missing" in error_message.lower()
122
+ ):
123
+ return f"""# Move Failed - Source File Missing
124
+
125
+ The source file for '{identifier}' was not found on disk: {error_message}
126
+
127
+ This usually means the database and filesystem are out of sync.
128
+
129
+ ## How to resolve:
130
+ 1. **Check if note exists in database**: `read_note("{identifier}")`
131
+ 2. **Run sync operation**: The file might need to be re-synced
132
+ 3. **Recreate the file**: If data exists in database, recreate the physical file
133
+
134
+ ## Troubleshooting steps:
135
+ ```
136
+ # Check if note exists in Basic Memory
137
+ read_note("{identifier}")
138
+
139
+ # If it exists, the file is missing on disk - send a message to support@basicmachines.co
140
+ # If it doesn't exist, use search to find the correct identifier
141
+ search_notes("{identifier}")
142
+ ```"""
143
+
144
+ # Server/filesystem errors
145
+ if (
146
+ "server error" in error_message.lower()
147
+ or "filesystem" in error_message.lower()
148
+ or "disk" in error_message.lower()
149
+ ):
150
+ return f"""# Move Failed - System Error
151
+
152
+ A system error occurred while moving '{identifier}': {error_message}
153
+
154
+ ## Immediate steps:
155
+ 1. **Try again**: The error might be temporary
156
+ 2. **Check disk space**: Ensure adequate storage is available
157
+ 3. **Verify filesystem permissions**: Check if the destination directory is writable
158
+
159
+ ## Alternative approaches:
160
+ - Copy content to new location: Use `read_note("{identifier}")` then `write_note()`
161
+ - Use a different destination folder that you know works
162
+ - Send a message to support@basicmachines.co if the problem persists
163
+
164
+ ## Backup approach:
165
+ ```
166
+ # Read current content
167
+ content = read_note("{identifier}")
168
+
169
+ # Create new note at desired location
170
+ write_note("New Note Title", content, "{destination_path.split("/")[0] if "/" in destination_path else "notes"}")
171
+
172
+ # Then delete original if successful
173
+ delete_note("{identifier}")
174
+ ```"""
175
+
176
+ # Generic fallback
177
+ return f"""# Move Failed
178
+
179
+ Error moving '{identifier}' to '{destination_path}': {error_message}
180
+
181
+ ## General troubleshooting:
182
+ 1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
183
+ 2. **Check destination path**: Ensure it's a valid relative path with `.md` extension
184
+ 3. **Verify permissions**: Make sure you can edit files in this project
185
+ 4. **Try a simpler path**: Use a basic folder structure like `notes/filename.md`
186
+
187
+ ## Step-by-step approach:
188
+ ```
189
+ # 1. Confirm note exists
190
+ read_note("{identifier}")
191
+
192
+ # 2. Try a simple destination first
193
+ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
194
+
195
+ # 3. If that works, then try your original destination
196
+ ```
197
+
198
+ ## Alternative approach:
199
+ If moving continues to fail, you can copy the content manually:
200
+ ```
201
+ # Read current content
202
+ content = read_note("{identifier}")
203
+
204
+ # Create new note
205
+ write_note("Title", content, "target-folder")
206
+
207
+ # Delete original once confirmed
208
+ delete_note("{identifier}")
209
+ ```"""
210
+
211
+
212
+ @mcp.tool(
213
+ description="Move a note to a new location, updating database and maintaining links.",
214
+ )
215
+ async def move_note(
216
+ identifier: str,
217
+ destination_path: str,
218
+ project: Optional[str] = None,
219
+ ) -> str:
220
+ """Move a note to a new file location within the same project.
221
+
222
+ Args:
223
+ identifier: Exact entity identifier (title, permalink, or memory:// URL).
224
+ Must be an exact match - fuzzy matching is not supported for move operations.
225
+ Use search_notes() or read_note() first to find the correct identifier if uncertain.
226
+ destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
227
+ project: Optional project name (defaults to current session project)
228
+
229
+ Returns:
230
+ Success message with move details
231
+
232
+ Examples:
233
+ # Move to new folder (exact title match)
234
+ move_note("My Note", "work/notes/my-note.md")
235
+
236
+ # Move by exact permalink
237
+ move_note("my-note-permalink", "archive/old-notes/my-note.md")
238
+
239
+ # Specify project with exact identifier
240
+ move_note("My Note", "archive/my-note.md", project="work-project")
241
+
242
+ # If uncertain about identifier, search first:
243
+ # search_notes("my note") # Find available notes
244
+ # move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
245
+
246
+ Note: This operation moves notes within the specified project only. Moving notes
247
+ between different projects is not currently supported.
248
+
249
+ The move operation:
250
+ - Updates the entity's file_path in the database
251
+ - Moves the physical file on the filesystem
252
+ - Optionally updates permalinks if configured
253
+ - Re-indexes the entity for search
254
+ - Maintains all observations and relations
255
+ """
256
+ logger.debug(f"Moving note: {identifier} to {destination_path}")
257
+
258
+ active_project = get_active_project(project)
259
+ project_url = active_project.project_url
260
+
261
+ try:
262
+ # Prepare move request
263
+ move_data = {
264
+ "identifier": identifier,
265
+ "destination_path": destination_path,
266
+ "project": active_project.name,
267
+ }
268
+
269
+ # Call the move API endpoint
270
+ url = f"{project_url}/knowledge/move"
271
+ response = await call_post(client, url, json=move_data)
272
+ result = EntityResponse.model_validate(response.json())
273
+
274
+ # Build success message
275
+ result_lines = [
276
+ "✅ Note moved successfully",
277
+ "",
278
+ f"📁 **{identifier}** → **{result.file_path}**",
279
+ f"🔗 Permalink: {result.permalink}",
280
+ "📊 Database and search index updated",
281
+ "",
282
+ f"<!-- Project: {active_project.name} -->",
283
+ ]
284
+
285
+ # Log the operation
286
+ logger.info(
287
+ "Move note completed",
288
+ identifier=identifier,
289
+ destination_path=destination_path,
290
+ project=active_project.name,
291
+ status_code=response.status_code,
292
+ )
293
+
294
+ return "\n".join(result_lines)
295
+
296
+ except Exception as e:
297
+ logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
298
+ # Return formatted error message for better user experience
299
+ return _format_move_error_response(str(e), identifier, destination_path)
@@ -0,0 +1,332 @@
1
+ """Project management tools for Basic Memory MCP server.
2
+
3
+ These tools allow users to switch between projects, list available projects,
4
+ and manage project context during conversations.
5
+ """
6
+
7
+ from textwrap import dedent
8
+
9
+ from fastmcp import Context
10
+ from loguru import logger
11
+
12
+ from basic_memory.config import get_project_config
13
+ from basic_memory.mcp.async_client import client
14
+ from basic_memory.mcp.project_session import session, add_project_metadata
15
+ from basic_memory.mcp.server import mcp
16
+ from basic_memory.mcp.tools.utils import call_get, call_put, call_post, call_delete
17
+ from basic_memory.schemas import ProjectInfoResponse
18
+ from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse, ProjectInfoRequest
19
+
20
+
21
+ @mcp.tool()
22
+ async def list_projects(ctx: Context | None = None) -> str:
23
+ """List all available projects with their status.
24
+
25
+ Shows all Basic Memory projects that are available, indicating which one
26
+ is currently active and which is the default.
27
+
28
+ Returns:
29
+ Formatted list of projects with status indicators
30
+
31
+ Example:
32
+ list_projects()
33
+ """
34
+ if ctx: # pragma: no cover
35
+ await ctx.info("Listing all available projects")
36
+
37
+ # Get projects from API
38
+ response = await call_get(client, "/projects/projects")
39
+ project_list = ProjectList.model_validate(response.json())
40
+
41
+ current = session.get_current_project()
42
+
43
+ result = "Available projects:\n"
44
+
45
+ for project in project_list.projects:
46
+ indicators = []
47
+ if project.name == current:
48
+ indicators.append("current")
49
+ if project.is_default:
50
+ indicators.append("default")
51
+
52
+ if indicators:
53
+ result += f"• {project.name} ({', '.join(indicators)})\n"
54
+ else:
55
+ result += f"• {project.name}\n"
56
+
57
+ return add_project_metadata(result, current)
58
+
59
+
60
+ @mcp.tool()
61
+ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
62
+ """Switch to a different project context.
63
+
64
+ Changes the active project context for all subsequent tool calls.
65
+ Shows a project summary after switching successfully.
66
+
67
+ Args:
68
+ project_name: Name of the project to switch to
69
+
70
+ Returns:
71
+ Confirmation message with project summary
72
+
73
+ Example:
74
+ switch_project("work-notes")
75
+ switch_project("personal-journal")
76
+ """
77
+ if ctx: # pragma: no cover
78
+ await ctx.info(f"Switching to project: {project_name}")
79
+
80
+ current_project = session.get_current_project()
81
+ try:
82
+ # Validate project exists by getting project list
83
+ response = await call_get(client, "/projects/projects")
84
+ project_list = ProjectList.model_validate(response.json())
85
+
86
+ # Check if project exists
87
+ project_exists = any(p.name == project_name for p in project_list.projects)
88
+ if not project_exists:
89
+ available_projects = [p.name for p in project_list.projects]
90
+ return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
91
+
92
+ # Switch to the project
93
+ session.set_current_project(project_name)
94
+ current_project = session.get_current_project()
95
+ project_config = get_project_config(current_project)
96
+
97
+ # Get project info to show summary
98
+ try:
99
+ response = await call_get(
100
+ client,
101
+ f"{project_config.project_url}/project/info",
102
+ params={"project_name": project_name},
103
+ )
104
+ project_info = ProjectInfoResponse.model_validate(response.json())
105
+
106
+ result = f"✓ Switched to {project_name} project\n\n"
107
+ result += "Project Summary:\n"
108
+ result += f"• {project_info.statistics.total_entities} entities\n"
109
+ result += f"• {project_info.statistics.total_observations} observations\n"
110
+ result += f"• {project_info.statistics.total_relations} relations\n"
111
+
112
+ except Exception as e:
113
+ # If we can't get project info, still confirm the switch
114
+ logger.warning(f"Could not get project info for {project_name}: {e}")
115
+ result = f"✓ Switched to {project_name} project\n\n"
116
+ result += "Project summary unavailable.\n"
117
+
118
+ return add_project_metadata(result, project_name)
119
+
120
+ except Exception as e:
121
+ logger.error(f"Error switching to project {project_name}: {e}")
122
+ # Revert to previous project on error
123
+ session.set_current_project(current_project)
124
+
125
+ # Return user-friendly error message instead of raising exception
126
+ return dedent(f"""
127
+ # Project Switch Failed
128
+
129
+ Could not switch to project '{project_name}': {str(e)}
130
+
131
+ ## Current project: {current_project}
132
+ Your session remains on the previous project.
133
+
134
+ ## Troubleshooting:
135
+ 1. **Check available projects**: Use `list_projects()` to see valid project names
136
+ 2. **Verify spelling**: Ensure the project name is spelled correctly
137
+ 3. **Check permissions**: Verify you have access to the requested project
138
+ 4. **Try again**: The error might be temporary
139
+
140
+ ## Available options:
141
+ - See all projects: `list_projects()`
142
+ - Stay on current project: `get_current_project()`
143
+ - Try different project: `switch_project("correct-project-name")`
144
+
145
+ If the project should exist but isn't listed, send a message to support@basicmachines.co.
146
+ """).strip()
147
+
148
+
149
+ @mcp.tool()
150
+ async def get_current_project(ctx: Context | None = None) -> str:
151
+ """Show the currently active project and basic stats.
152
+
153
+ Displays which project is currently active and provides basic information
154
+ about it.
155
+
156
+ Returns:
157
+ Current project name and basic statistics
158
+
159
+ Example:
160
+ get_current_project()
161
+ """
162
+ if ctx: # pragma: no cover
163
+ await ctx.info("Getting current project information")
164
+
165
+ current_project = session.get_current_project()
166
+ project_config = get_project_config(current_project)
167
+ result = f"Current project: {current_project}\n\n"
168
+
169
+ # get project stats
170
+ response = await call_get(
171
+ client,
172
+ f"{project_config.project_url}/project/info",
173
+ params={"project_name": current_project},
174
+ )
175
+ project_info = ProjectInfoResponse.model_validate(response.json())
176
+
177
+ result += f"• {project_info.statistics.total_entities} entities\n"
178
+ result += f"• {project_info.statistics.total_observations} observations\n"
179
+ result += f"• {project_info.statistics.total_relations} relations\n"
180
+
181
+ default_project = session.get_default_project()
182
+ if current_project != default_project:
183
+ result += f"• Default project: {default_project}\n"
184
+
185
+ return add_project_metadata(result, current_project)
186
+
187
+
188
+ @mcp.tool()
189
+ async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
190
+ """Set default project in config. Requires restart to take effect.
191
+
192
+ Updates the configuration to use a different default project. This change
193
+ only takes effect after restarting the Basic Memory server.
194
+
195
+ Args:
196
+ project_name: Name of the project to set as default
197
+
198
+ Returns:
199
+ Confirmation message about config update
200
+
201
+ Example:
202
+ set_default_project("work-notes")
203
+ """
204
+ if ctx: # pragma: no cover
205
+ await ctx.info(f"Setting default project to: {project_name}")
206
+
207
+ # Call API to set default project
208
+ response = await call_put(client, f"/projects/{project_name}/default")
209
+ status_response = ProjectStatusResponse.model_validate(response.json())
210
+
211
+ result = f"✓ {status_response.message}\n\n"
212
+ result += "Restart Basic Memory for this change to take effect:\n"
213
+ result += "basic-memory mcp\n"
214
+
215
+ if status_response.old_project:
216
+ result += f"\nPrevious default: {status_response.old_project.name}\n"
217
+
218
+ return add_project_metadata(result, session.get_current_project())
219
+
220
+
221
+ @mcp.tool()
222
+ async def create_project(
223
+ project_name: str, project_path: str, set_default: bool = False, ctx: Context | None = None
224
+ ) -> str:
225
+ """Create a new Basic Memory project.
226
+
227
+ Creates a new project with the specified name and path. The project directory
228
+ will be created if it doesn't exist. Optionally sets the new project as default.
229
+
230
+ Args:
231
+ project_name: Name for the new project (must be unique)
232
+ project_path: File system path where the project will be stored
233
+ set_default: Whether to set this project as the default (optional, defaults to False)
234
+
235
+ Returns:
236
+ Confirmation message with project details
237
+
238
+ Example:
239
+ create_project("my-research", "~/Documents/research")
240
+ create_project("work-notes", "/home/user/work", set_default=True)
241
+ """
242
+ if ctx: # pragma: no cover
243
+ await ctx.info(f"Creating project: {project_name} at {project_path}")
244
+
245
+ # Create the project request
246
+ project_request = ProjectInfoRequest(
247
+ name=project_name, path=project_path, set_default=set_default
248
+ )
249
+
250
+ # Call API to create project
251
+ response = await call_post(client, "/projects/projects", json=project_request.model_dump())
252
+ status_response = ProjectStatusResponse.model_validate(response.json())
253
+
254
+ result = f"✓ {status_response.message}\n\n"
255
+
256
+ if status_response.new_project:
257
+ result += "Project Details:\n"
258
+ result += f"• Name: {status_response.new_project.name}\n"
259
+ result += f"• Path: {status_response.new_project.path}\n"
260
+
261
+ if set_default:
262
+ result += "• Set as default project\n"
263
+
264
+ result += "\nProject is now available for use.\n"
265
+
266
+ # If project was set as default, update session
267
+ if set_default:
268
+ session.set_current_project(project_name)
269
+
270
+ return add_project_metadata(result, session.get_current_project())
271
+
272
+
273
+ @mcp.tool()
274
+ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
275
+ """Delete a Basic Memory project.
276
+
277
+ Removes a project from the configuration and database. This does NOT delete
278
+ the actual files on disk - only removes the project from Basic Memory's
279
+ configuration and database records.
280
+
281
+ Args:
282
+ project_name: Name of the project to delete
283
+
284
+ Returns:
285
+ Confirmation message about project deletion
286
+
287
+ Example:
288
+ delete_project("old-project")
289
+
290
+ Warning:
291
+ This action cannot be undone. The project will need to be re-added
292
+ to access its content through Basic Memory again.
293
+ """
294
+ if ctx: # pragma: no cover
295
+ await ctx.info(f"Deleting project: {project_name}")
296
+
297
+ current_project = session.get_current_project()
298
+
299
+ # Check if trying to delete current project
300
+ if project_name == current_project:
301
+ raise ValueError(
302
+ f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
303
+ )
304
+
305
+ # Get project info before deletion to validate it exists
306
+ response = await call_get(client, "/projects/projects")
307
+ project_list = ProjectList.model_validate(response.json())
308
+
309
+ # Check if project exists
310
+ project_exists = any(p.name == project_name for p in project_list.projects)
311
+ if not project_exists:
312
+ available_projects = [p.name for p in project_list.projects]
313
+ raise ValueError(
314
+ f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
315
+ )
316
+
317
+ # Call API to delete project
318
+ response = await call_delete(client, f"/projects/{project_name}")
319
+ status_response = ProjectStatusResponse.model_validate(response.json())
320
+
321
+ result = f"✓ {status_response.message}\n\n"
322
+
323
+ if status_response.old_project:
324
+ result += "Removed project details:\n"
325
+ result += f"• Name: {status_response.old_project.name}\n"
326
+ if hasattr(status_response.old_project, "path"):
327
+ result += f"• Path: {status_response.old_project.path}\n"
328
+
329
+ result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
330
+ result += "Re-add the project to access its content again.\n"
331
+
332
+ return add_project_metadata(result, session.get_current_project())
@@ -5,17 +5,19 @@ supporting various file types including text, images, and other binary files.
5
5
  Files are read directly without any knowledge graph processing.
6
6
  """
7
7
 
8
+ from typing import Optional
9
+ import base64
10
+ import io
11
+
8
12
  from loguru import logger
13
+ from PIL import Image as PILImage
9
14
 
10
15
  from basic_memory.mcp.server import mcp
11
16
  from basic_memory.mcp.async_client import client
12
17
  from basic_memory.mcp.tools.utils import call_get
18
+ from basic_memory.mcp.project_session import get_active_project
13
19
  from basic_memory.schemas.memory import memory_url_path
14
20
 
15
- import base64
16
- import io
17
- from PIL import Image as PILImage
18
-
19
21
 
20
22
  def calculate_target_params(content_length):
21
23
  """Calculate initial quality and size based on input file size"""
@@ -144,7 +146,7 @@ def optimize_image(img, content_length, max_output_bytes=350000):
144
146
 
145
147
 
146
148
  @mcp.tool(description="Read a file's raw content by path or permalink")
147
- async def read_content(path: str) -> dict:
149
+ async def read_content(path: str, project: Optional[str] = None) -> dict:
148
150
  """Read a file's raw content by path or permalink.
149
151
 
150
152
  This tool provides direct access to file content in the knowledge base,
@@ -158,6 +160,7 @@ async def read_content(path: str) -> dict:
158
160
  - A regular file path (docs/example.md)
159
161
  - A memory URL (memory://docs/example)
160
162
  - A permalink (docs/example)
163
+ project: Optional project name to read from. If not provided, uses current active project.
161
164
 
162
165
  Returns:
163
166
  A dictionary with the file content and metadata:
@@ -175,11 +178,17 @@ async def read_content(path: str) -> dict:
175
178
 
176
179
  # Read using memory URL
177
180
  content = await read_file("memory://docs/architecture")
181
+
182
+ # Read from specific project
183
+ content = await read_content("docs/example.md", project="work-project")
178
184
  """
179
185
  logger.info("Reading file", path=path)
180
186
 
187
+ active_project = get_active_project(project)
188
+ project_url = active_project.project_url
189
+
181
190
  url = memory_url_path(path)
182
- response = await call_get(client, f"/resource/{url}")
191
+ response = await call_get(client, f"{project_url}/resource/{url}")
183
192
  content_type = response.headers.get("content-type", "application/octet-stream")
184
193
  content_length = int(response.headers.get("content-length", 0))
185
194