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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +100 -4
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +43 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +77 -60
- basic_memory/cli/commands/project.py +154 -152
- basic_memory/cli/commands/status.py +25 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +131 -21
- basic_memory/db.py +104 -3
- basic_memory/deps.py +27 -8
- basic_memory/file_utils.py +37 -13
- basic_memory/ignore_utils.py +295 -0
- basic_memory/markdown/plugins.py +9 -7
- basic_memory/mcp/async_client.py +124 -14
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +17 -16
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +13 -12
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
- basic_memory/mcp/resources/project_info.py +27 -11
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +67 -56
- basic_memory/mcp/tools/canvas.py +38 -26
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +81 -47
- basic_memory/mcp/tools/edit_note.py +155 -138
- basic_memory/mcp/tools/list_directory.py +112 -99
- basic_memory/mcp/tools/move_note.py +181 -101
- basic_memory/mcp/tools/project_management.py +113 -277
- basic_memory/mcp/tools/read_content.py +91 -74
- basic_memory/mcp/tools/read_note.py +152 -115
- basic_memory/mcp/tools/recent_activity.py +471 -68
- basic_memory/mcp/tools/search.py +105 -92
- basic_memory/mcp/tools/sync_status.py +136 -130
- basic_memory/mcp/tools/utils.py +4 -0
- basic_memory/mcp/tools/view_note.py +44 -33
- basic_memory/mcp/tools/write_note.py +151 -90
- basic_memory/models/knowledge.py +12 -6
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +89 -82
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +18 -5
- basic_memory/repository/search_repository.py +46 -2
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +39 -11
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +90 -21
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +25 -11
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +100 -48
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +101 -24
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +173 -34
- basic_memory/sync/watch_service.py +101 -40
- basic_memory/utils.py +14 -4
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
- basic_memory-0.15.1.dist-info/RECORD +146 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.4.dist-info/RECORD +0 -133
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,242 +4,76 @@ These tools allow users to switch between projects, list available projects,
|
|
|
4
4
|
and manage project context during conversations.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
from typing import Optional
|
|
9
|
-
|
|
7
|
+
import os
|
|
10
8
|
from fastmcp import Context
|
|
11
|
-
from loguru import logger
|
|
12
9
|
|
|
13
|
-
from basic_memory.mcp.async_client import
|
|
14
|
-
from basic_memory.mcp.project_session import session, add_project_metadata
|
|
10
|
+
from basic_memory.mcp.async_client import get_client
|
|
15
11
|
from basic_memory.mcp.server import mcp
|
|
16
|
-
from basic_memory.mcp.tools.utils import call_get,
|
|
17
|
-
from basic_memory.schemas import
|
|
18
|
-
|
|
12
|
+
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
|
|
13
|
+
from basic_memory.schemas.project_info import (
|
|
14
|
+
ProjectList,
|
|
15
|
+
ProjectStatusResponse,
|
|
16
|
+
ProjectInfoRequest,
|
|
17
|
+
)
|
|
19
18
|
from basic_memory.utils import generate_permalink
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
@mcp.tool("list_memory_projects")
|
|
23
|
-
async def list_memory_projects(
|
|
24
|
-
ctx: Context | None = None, _compatibility: Optional[str] = None
|
|
25
|
-
) -> str:
|
|
22
|
+
async def list_memory_projects(context: Context | None = None) -> str:
|
|
26
23
|
"""List all available projects with their status.
|
|
27
24
|
|
|
28
|
-
Shows all Basic Memory projects that are available
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Returns:
|
|
32
|
-
Formatted list of projects with status indicators
|
|
33
|
-
|
|
34
|
-
Example:
|
|
35
|
-
list_memory_projects()
|
|
36
|
-
"""
|
|
37
|
-
if ctx: # pragma: no cover
|
|
38
|
-
await ctx.info("Listing all available projects")
|
|
39
|
-
|
|
40
|
-
# Get projects from API
|
|
41
|
-
response = await call_get(client, "/projects/projects")
|
|
42
|
-
project_list = ProjectList.model_validate(response.json())
|
|
43
|
-
|
|
44
|
-
current = session.get_current_project()
|
|
45
|
-
|
|
46
|
-
result = "Available projects:\n"
|
|
47
|
-
|
|
48
|
-
for project in project_list.projects:
|
|
49
|
-
indicators = []
|
|
50
|
-
if project.name == current:
|
|
51
|
-
indicators.append("current")
|
|
52
|
-
if project.is_default:
|
|
53
|
-
indicators.append("default")
|
|
25
|
+
Shows all Basic Memory projects that are available for MCP operations.
|
|
26
|
+
Use this tool to discover projects when you need to know which project to use.
|
|
54
27
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return add_project_metadata(result, current)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@mcp.tool()
|
|
64
|
-
async def switch_project(project_name: str, ctx: Context | None = None) -> str:
|
|
65
|
-
"""Switch to a different project context.
|
|
66
|
-
|
|
67
|
-
Changes the active project context for all subsequent tool calls.
|
|
68
|
-
Shows a project summary after switching successfully.
|
|
28
|
+
Use this tool:
|
|
29
|
+
- At conversation start when project is unknown
|
|
30
|
+
- When user asks about available projects
|
|
31
|
+
- Before any operation requiring a project
|
|
69
32
|
|
|
70
|
-
|
|
71
|
-
|
|
33
|
+
After calling:
|
|
34
|
+
- Ask user which project to use
|
|
35
|
+
- Remember their choice for the session
|
|
72
36
|
|
|
73
37
|
Returns:
|
|
74
|
-
|
|
38
|
+
Formatted list of projects with session management guidance
|
|
75
39
|
|
|
76
40
|
Example:
|
|
77
|
-
|
|
78
|
-
switch_project("personal-journal")
|
|
41
|
+
list_memory_projects()
|
|
79
42
|
"""
|
|
80
|
-
|
|
81
|
-
|
|
43
|
+
async with get_client() as client:
|
|
44
|
+
if context: # pragma: no cover
|
|
45
|
+
await context.info("Listing all available projects")
|
|
46
|
+
|
|
47
|
+
# Check if server is constrained to a specific project
|
|
48
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
82
49
|
|
|
83
|
-
|
|
84
|
-
current_project = session.get_current_project()
|
|
85
|
-
try:
|
|
86
|
-
# Validate project exists by getting project list
|
|
50
|
+
# Get projects from API
|
|
87
51
|
response = await call_get(client, "/projects/projects")
|
|
88
52
|
project_list = ProjectList.model_validate(response.json())
|
|
89
53
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# Also match by name comparison (case-insensitive)
|
|
98
|
-
if p.name.lower() == project_name.lower():
|
|
99
|
-
target_project = p
|
|
100
|
-
break
|
|
101
|
-
|
|
102
|
-
if not target_project:
|
|
103
|
-
available_projects = [p.name for p in project_list.projects]
|
|
104
|
-
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
105
|
-
|
|
106
|
-
# Switch to the project using the canonical name from database
|
|
107
|
-
canonical_name = target_project.name
|
|
108
|
-
session.set_current_project(canonical_name)
|
|
109
|
-
current_project = session.get_current_project()
|
|
110
|
-
|
|
111
|
-
# Get project info to show summary
|
|
112
|
-
try:
|
|
113
|
-
current_project_permalink = generate_permalink(canonical_name)
|
|
114
|
-
response = await call_get(
|
|
115
|
-
client,
|
|
116
|
-
f"/{current_project_permalink}/project/info",
|
|
117
|
-
params={"project_name": canonical_name},
|
|
118
|
-
)
|
|
119
|
-
project_info = ProjectInfoResponse.model_validate(response.json())
|
|
120
|
-
|
|
121
|
-
result = f"✓ Switched to {canonical_name} project\n\n"
|
|
122
|
-
result += "Project Summary:\n"
|
|
123
|
-
result += f"• {project_info.statistics.total_entities} entities\n"
|
|
124
|
-
result += f"• {project_info.statistics.total_observations} observations\n"
|
|
125
|
-
result += f"• {project_info.statistics.total_relations} relations\n"
|
|
126
|
-
|
|
127
|
-
except Exception as e:
|
|
128
|
-
# If we can't get project info, still confirm the switch
|
|
129
|
-
logger.warning(f"Could not get project info for {canonical_name}: {e}")
|
|
130
|
-
result = f"✓ Switched to {canonical_name} project\n\n"
|
|
131
|
-
result += "Project summary unavailable.\n"
|
|
132
|
-
|
|
133
|
-
return add_project_metadata(result, canonical_name)
|
|
134
|
-
|
|
135
|
-
except Exception as e:
|
|
136
|
-
logger.error(f"Error switching to project {project_name}: {e}")
|
|
137
|
-
# Revert to previous project on error
|
|
138
|
-
session.set_current_project(current_project)
|
|
139
|
-
|
|
140
|
-
# Return user-friendly error message instead of raising exception
|
|
141
|
-
return dedent(f"""
|
|
142
|
-
# Project Switch Failed
|
|
143
|
-
|
|
144
|
-
Could not switch to project '{project_name}': {str(e)}
|
|
145
|
-
|
|
146
|
-
## Current project: {current_project}
|
|
147
|
-
Your session remains on the previous project.
|
|
148
|
-
|
|
149
|
-
## Troubleshooting:
|
|
150
|
-
1. **Check available projects**: Use `list_memory_projects()` to see valid project names
|
|
151
|
-
2. **Verify spelling**: Ensure the project name is spelled correctly
|
|
152
|
-
3. **Check permissions**: Verify you have access to the requested project
|
|
153
|
-
4. **Try again**: The error might be temporary
|
|
154
|
-
|
|
155
|
-
## Available options:
|
|
156
|
-
- See all projects: `list_memory_projects()`
|
|
157
|
-
- Stay on current project: `get_current_project()`
|
|
158
|
-
- Try different project: `switch_project("correct-project-name")`
|
|
159
|
-
|
|
160
|
-
If the project should exist but isn't listed, send a message to support@basicmachines.co.
|
|
161
|
-
""").strip()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@mcp.tool()
|
|
165
|
-
async def get_current_project(
|
|
166
|
-
ctx: Context | None = None, _compatibility: Optional[str] = None
|
|
167
|
-
) -> str:
|
|
168
|
-
"""Show the currently active project and basic stats.
|
|
169
|
-
|
|
170
|
-
Displays which project is currently active and provides basic information
|
|
171
|
-
about it.
|
|
172
|
-
|
|
173
|
-
Returns:
|
|
174
|
-
Current project name and basic statistics
|
|
175
|
-
|
|
176
|
-
Example:
|
|
177
|
-
get_current_project()
|
|
178
|
-
"""
|
|
179
|
-
if ctx: # pragma: no cover
|
|
180
|
-
await ctx.info("Getting current project information")
|
|
181
|
-
|
|
182
|
-
current_project = session.get_current_project()
|
|
183
|
-
result = f"Current project: {current_project}\n\n"
|
|
184
|
-
|
|
185
|
-
# get project stats (use permalink in URL path)
|
|
186
|
-
current_project_permalink = generate_permalink(current_project)
|
|
187
|
-
response = await call_get(
|
|
188
|
-
client,
|
|
189
|
-
f"/{current_project_permalink}/project/info",
|
|
190
|
-
params={"project_name": current_project},
|
|
191
|
-
)
|
|
192
|
-
project_info = ProjectInfoResponse.model_validate(response.json())
|
|
193
|
-
|
|
194
|
-
result += f"• {project_info.statistics.total_entities} entities\n"
|
|
195
|
-
result += f"• {project_info.statistics.total_observations} observations\n"
|
|
196
|
-
result += f"• {project_info.statistics.total_relations} relations\n"
|
|
197
|
-
|
|
198
|
-
default_project = session.get_default_project()
|
|
199
|
-
if current_project != default_project:
|
|
200
|
-
result += f"• Default project: {default_project}\n"
|
|
201
|
-
|
|
202
|
-
return add_project_metadata(result, current_project)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@mcp.tool()
|
|
206
|
-
async def set_default_project(project_name: str, ctx: Context | None = None) -> str:
|
|
207
|
-
"""Set default project in config. Requires restart to take effect.
|
|
208
|
-
|
|
209
|
-
Updates the configuration to use a different default project. This change
|
|
210
|
-
only takes effect after restarting the Basic Memory server.
|
|
211
|
-
|
|
212
|
-
Args:
|
|
213
|
-
project_name: Name of the project to set as default
|
|
214
|
-
|
|
215
|
-
Returns:
|
|
216
|
-
Confirmation message about config update
|
|
217
|
-
|
|
218
|
-
Example:
|
|
219
|
-
set_default_project("work-notes")
|
|
220
|
-
"""
|
|
221
|
-
if ctx: # pragma: no cover
|
|
222
|
-
await ctx.info(f"Setting default project to: {project_name}")
|
|
223
|
-
|
|
224
|
-
# Call API to set default project using URL encoding for special characters
|
|
225
|
-
from urllib.parse import quote
|
|
226
|
-
encoded_name = quote(project_name, safe='')
|
|
227
|
-
response = await call_put(client, f"/projects/{encoded_name}/default")
|
|
228
|
-
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
54
|
+
if constrained_project:
|
|
55
|
+
result = f"Project: {constrained_project}\n\n"
|
|
56
|
+
result += "Note: This MCP server is constrained to a single project.\n"
|
|
57
|
+
result += "All operations will automatically use this project."
|
|
58
|
+
else:
|
|
59
|
+
# Show all projects with session guidance
|
|
60
|
+
result = "Available projects:\n"
|
|
229
61
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
result += "basic-memory mcp\n"
|
|
62
|
+
for project in project_list.projects:
|
|
63
|
+
result += f"• {project.name}\n"
|
|
233
64
|
|
|
234
|
-
|
|
235
|
-
|
|
65
|
+
result += "\n" + "─" * 40 + "\n"
|
|
66
|
+
result += "Next: Ask which project to use for this session.\n"
|
|
67
|
+
result += "Example: 'Which project should I use for this task?'\n\n"
|
|
68
|
+
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
|
|
69
|
+
result += "The user can say 'switch to [project]' to change projects."
|
|
236
70
|
|
|
237
|
-
|
|
71
|
+
return result
|
|
238
72
|
|
|
239
73
|
|
|
240
74
|
@mcp.tool("create_memory_project")
|
|
241
75
|
async def create_memory_project(
|
|
242
|
-
project_name: str, project_path: str, set_default: bool = False,
|
|
76
|
+
project_name: str, project_path: str, set_default: bool = False, context: Context | None = None
|
|
243
77
|
) -> str:
|
|
244
78
|
"""Create a new Basic Memory project.
|
|
245
79
|
|
|
@@ -258,39 +92,42 @@ async def create_memory_project(
|
|
|
258
92
|
create_memory_project("my-research", "~/Documents/research")
|
|
259
93
|
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
|
260
94
|
"""
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
95
|
+
async with get_client() as client:
|
|
96
|
+
# Check if server is constrained to a specific project
|
|
97
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
98
|
+
if constrained_project:
|
|
99
|
+
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
|
|
100
|
+
|
|
101
|
+
if context: # pragma: no cover
|
|
102
|
+
await context.info(f"Creating project: {project_name} at {project_path}")
|
|
103
|
+
|
|
104
|
+
# Create the project request
|
|
105
|
+
project_request = ProjectInfoRequest(
|
|
106
|
+
name=project_name, path=project_path, set_default=set_default
|
|
107
|
+
)
|
|
272
108
|
|
|
273
|
-
|
|
109
|
+
# Call API to create project
|
|
110
|
+
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
|
|
111
|
+
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
274
112
|
|
|
275
|
-
|
|
276
|
-
result += "Project Details:\n"
|
|
277
|
-
result += f"• Name: {status_response.new_project.name}\n"
|
|
278
|
-
result += f"• Path: {status_response.new_project.path}\n"
|
|
113
|
+
result = f"✓ {status_response.message}\n\n"
|
|
279
114
|
|
|
280
|
-
if
|
|
281
|
-
result += "
|
|
115
|
+
if status_response.new_project:
|
|
116
|
+
result += "Project Details:\n"
|
|
117
|
+
result += f"• Name: {status_response.new_project.name}\n"
|
|
118
|
+
result += f"• Path: {status_response.new_project.path}\n"
|
|
282
119
|
|
|
283
|
-
|
|
120
|
+
if set_default:
|
|
121
|
+
result += "• Set as default project\n"
|
|
284
122
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
session.set_current_project(project_name)
|
|
123
|
+
result += "\nProject is now available for use in tool calls.\n"
|
|
124
|
+
result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
|
|
288
125
|
|
|
289
|
-
|
|
126
|
+
return result
|
|
290
127
|
|
|
291
128
|
|
|
292
129
|
@mcp.tool()
|
|
293
|
-
async def delete_project(project_name: str,
|
|
130
|
+
async def delete_project(project_name: str, context: Context | None = None) -> str:
|
|
294
131
|
"""Delete a Basic Memory project.
|
|
295
132
|
|
|
296
133
|
Removes a project from the configuration and database. This does NOT delete
|
|
@@ -310,55 +147,54 @@ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
|
|
|
310
147
|
This action cannot be undone. The project will need to be re-added
|
|
311
148
|
to access its content through Basic Memory again.
|
|
312
149
|
"""
|
|
313
|
-
|
|
314
|
-
|
|
150
|
+
async with get_client() as client:
|
|
151
|
+
# Check if server is constrained to a specific project
|
|
152
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
153
|
+
if constrained_project:
|
|
154
|
+
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
|
|
315
155
|
|
|
316
|
-
|
|
156
|
+
if context: # pragma: no cover
|
|
157
|
+
await context.info(f"Deleting project: {project_name}")
|
|
317
158
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
f"Cannot delete the currently active project '{project_name}'. Switch to a different project first."
|
|
322
|
-
)
|
|
159
|
+
# Get project info before deletion to validate it exists
|
|
160
|
+
response = await call_get(client, "/projects/projects")
|
|
161
|
+
project_list = ProjectList.model_validate(response.json())
|
|
323
162
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
)
|
|
163
|
+
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
|
|
164
|
+
project_permalink = generate_permalink(project_name)
|
|
165
|
+
target_project = None
|
|
166
|
+
for p in project_list.projects:
|
|
167
|
+
# Match by permalink (handles case-insensitive input)
|
|
168
|
+
if p.permalink == project_permalink:
|
|
169
|
+
target_project = p
|
|
170
|
+
break
|
|
171
|
+
# Also match by name comparison (case-insensitive)
|
|
172
|
+
if p.name.lower() == project_name.lower():
|
|
173
|
+
target_project = p
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if not target_project:
|
|
177
|
+
available_projects = [p.name for p in project_list.projects]
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Call API to delete project using URL encoding for special characters
|
|
183
|
+
from urllib.parse import quote
|
|
346
184
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
response = await call_delete(client, f"/projects/{encoded_name}")
|
|
351
|
-
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
185
|
+
encoded_name = quote(target_project.name, safe="")
|
|
186
|
+
response = await call_delete(client, f"/projects/{encoded_name}")
|
|
187
|
+
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
352
188
|
|
|
353
|
-
|
|
189
|
+
result = f"✓ {status_response.message}\n\n"
|
|
354
190
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
191
|
+
if status_response.old_project:
|
|
192
|
+
result += "Removed project details:\n"
|
|
193
|
+
result += f"• Name: {status_response.old_project.name}\n"
|
|
194
|
+
if hasattr(status_response.old_project, "path"):
|
|
195
|
+
result += f"• Path: {status_response.old_project.path}\n"
|
|
360
196
|
|
|
361
|
-
|
|
362
|
-
|
|
197
|
+
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
|
|
198
|
+
result += "Re-add the project to access its content again.\n"
|
|
363
199
|
|
|
364
|
-
|
|
200
|
+
return result
|
|
@@ -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
8
|
import base64
|
|
10
9
|
import io
|
|
11
10
|
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
12
13
|
from loguru import logger
|
|
13
14
|
from PIL import Image as PILImage
|
|
15
|
+
from fastmcp import Context
|
|
14
16
|
|
|
17
|
+
from basic_memory.mcp.project_context import get_active_project
|
|
15
18
|
from basic_memory.mcp.server import mcp
|
|
16
|
-
from basic_memory.mcp.async_client import
|
|
19
|
+
from basic_memory.mcp.async_client import get_client
|
|
17
20
|
from basic_memory.mcp.tools.utils import call_get
|
|
18
|
-
from basic_memory.mcp.project_session import get_active_project
|
|
19
21
|
from basic_memory.schemas.memory import memory_url_path
|
|
20
22
|
from basic_memory.utils import validate_project_path
|
|
21
23
|
|
|
@@ -147,11 +149,16 @@ def optimize_image(img, content_length, max_output_bytes=350000):
|
|
|
147
149
|
|
|
148
150
|
|
|
149
151
|
@mcp.tool(description="Read a file's raw content by path or permalink")
|
|
150
|
-
async def read_content(
|
|
152
|
+
async def read_content(
|
|
153
|
+
path: str, project: Optional[str] = None, context: Context | None = None
|
|
154
|
+
) -> dict:
|
|
151
155
|
"""Read a file's raw content by path or permalink.
|
|
152
156
|
|
|
153
157
|
This tool provides direct access to file content in the knowledge base,
|
|
154
|
-
handling different file types appropriately
|
|
158
|
+
handling different file types appropriately. Uses stateless architecture -
|
|
159
|
+
project parameter optional with server resolution.
|
|
160
|
+
|
|
161
|
+
Supported file types:
|
|
155
162
|
- Text files (markdown, code, etc.) are returned as plain text
|
|
156
163
|
- Images are automatically resized/optimized for display
|
|
157
164
|
- Other binary files are returned as base64 if below size limits
|
|
@@ -161,7 +168,9 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
|
|
|
161
168
|
- A regular file path (docs/example.md)
|
|
162
169
|
- A memory URL (memory://docs/example)
|
|
163
170
|
- A permalink (docs/example)
|
|
164
|
-
project:
|
|
171
|
+
project: Project name to read from. Optional - server will resolve using hierarchy.
|
|
172
|
+
If unknown, use list_memory_projects() to discover available projects.
|
|
173
|
+
context: Optional FastMCP context for performance caching.
|
|
165
174
|
|
|
166
175
|
Returns:
|
|
167
176
|
A dictionary with the file content and metadata:
|
|
@@ -172,83 +181,91 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
|
|
|
172
181
|
|
|
173
182
|
Examples:
|
|
174
183
|
# Read a markdown file
|
|
175
|
-
result = await
|
|
184
|
+
result = await read_content("docs/project-specs.md")
|
|
176
185
|
|
|
177
186
|
# Read an image
|
|
178
|
-
image_data = await
|
|
187
|
+
image_data = await read_content("assets/diagram.png")
|
|
179
188
|
|
|
180
189
|
# Read using memory URL
|
|
181
|
-
content = await
|
|
190
|
+
content = await read_content("memory://docs/architecture")
|
|
191
|
+
|
|
192
|
+
# Read configuration file
|
|
193
|
+
config = await read_content("config/settings.json")
|
|
194
|
+
|
|
195
|
+
# Explicit project specification
|
|
196
|
+
result = await read_content("docs/project-specs.md", project="my-project")
|
|
182
197
|
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
Raises:
|
|
199
|
+
HTTPError: If project doesn't exist or is inaccessible
|
|
200
|
+
SecurityError: If path attempts path traversal
|
|
185
201
|
"""
|
|
186
|
-
logger.info("Reading file", path=path)
|
|
202
|
+
logger.info("Reading file", path=path, project=project)
|
|
187
203
|
|
|
188
|
-
|
|
189
|
-
|
|
204
|
+
async with get_client() as client:
|
|
205
|
+
active_project = await get_active_project(client, project, context)
|
|
206
|
+
project_url = active_project.project_url
|
|
190
207
|
|
|
191
|
-
|
|
208
|
+
url = memory_url_path(path)
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
"type": "error",
|
|
204
|
-
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
208
|
-
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
209
|
-
content_length = int(response.headers.get("content-length", 0))
|
|
210
|
-
|
|
211
|
-
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
|
|
212
|
-
|
|
213
|
-
# Handle text or json
|
|
214
|
-
if content_type.startswith("text/") or content_type == "application/json":
|
|
215
|
-
logger.debug("Processing text resource")
|
|
216
|
-
return {
|
|
217
|
-
"type": "text",
|
|
218
|
-
"text": response.text,
|
|
219
|
-
"content_type": content_type,
|
|
220
|
-
"encoding": "utf-8",
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
# Handle images
|
|
224
|
-
elif content_type.startswith("image/"):
|
|
225
|
-
logger.debug("Processing image")
|
|
226
|
-
img = PILImage.open(io.BytesIO(response.content))
|
|
227
|
-
img_bytes = optimize_image(img, content_length)
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
"type": "image",
|
|
231
|
-
"source": {
|
|
232
|
-
"type": "base64",
|
|
233
|
-
"media_type": "image/jpeg",
|
|
234
|
-
"data": base64.b64encode(img_bytes).decode("utf-8"),
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
# Handle other file types
|
|
239
|
-
else:
|
|
240
|
-
logger.debug(f"Processing binary resource content_type {content_type}")
|
|
241
|
-
if content_length > 350000: # pragma: no cover
|
|
242
|
-
logger.warning("Document too large for response", size=content_length)
|
|
210
|
+
# Validate path to prevent path traversal attacks
|
|
211
|
+
project_path = active_project.home
|
|
212
|
+
if not validate_project_path(url, project_path):
|
|
213
|
+
logger.warning(
|
|
214
|
+
"Attempted path traversal attack blocked",
|
|
215
|
+
path=path,
|
|
216
|
+
url=url,
|
|
217
|
+
project=active_project.name,
|
|
218
|
+
)
|
|
243
219
|
return {
|
|
244
220
|
"type": "error",
|
|
245
|
-
"error": f"
|
|
221
|
+
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
225
|
+
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
226
|
+
content_length = int(response.headers.get("content-length", 0))
|
|
227
|
+
|
|
228
|
+
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
|
|
229
|
+
|
|
230
|
+
# Handle text or json
|
|
231
|
+
if content_type.startswith("text/") or content_type == "application/json":
|
|
232
|
+
logger.debug("Processing text resource")
|
|
233
|
+
return {
|
|
234
|
+
"type": "text",
|
|
235
|
+
"text": response.text,
|
|
236
|
+
"content_type": content_type,
|
|
237
|
+
"encoding": "utf-8",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# Handle images
|
|
241
|
+
elif content_type.startswith("image/"):
|
|
242
|
+
logger.debug("Processing image")
|
|
243
|
+
img = PILImage.open(io.BytesIO(response.content))
|
|
244
|
+
img_bytes = optimize_image(img, content_length)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"type": "image",
|
|
248
|
+
"source": {
|
|
249
|
+
"type": "base64",
|
|
250
|
+
"media_type": "image/jpeg",
|
|
251
|
+
"data": base64.b64encode(img_bytes).decode("utf-8"),
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Handle other file types
|
|
256
|
+
else:
|
|
257
|
+
logger.debug(f"Processing binary resource content_type {content_type}")
|
|
258
|
+
if content_length > 350000: # pragma: no cover
|
|
259
|
+
logger.warning("Document too large for response", size=content_length)
|
|
260
|
+
return {
|
|
261
|
+
"type": "error",
|
|
262
|
+
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
"type": "document",
|
|
266
|
+
"source": {
|
|
267
|
+
"type": "base64",
|
|
268
|
+
"media_type": content_type,
|
|
269
|
+
"data": base64.b64encode(response.content).decode("utf-8"),
|
|
270
|
+
},
|
|
246
271
|
}
|
|
247
|
-
return {
|
|
248
|
-
"type": "document",
|
|
249
|
-
"source": {
|
|
250
|
-
"type": "base64",
|
|
251
|
-
"media_type": content_type,
|
|
252
|
-
"data": base64.b64encode(response.content).decode("utf-8"),
|
|
253
|
-
},
|
|
254
|
-
}
|