basic-memory 0.16.1__py3-none-any.whl → 0.17.4__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/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/mcp/tools/canvas.py
CHANGED
|
@@ -12,7 +12,8 @@ from fastmcp import Context
|
|
|
12
12
|
from basic_memory.mcp.async_client import get_client
|
|
13
13
|
from basic_memory.mcp.project_context import get_active_project
|
|
14
14
|
from basic_memory.mcp.server import mcp
|
|
15
|
-
from basic_memory.mcp.tools.utils import call_put
|
|
15
|
+
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
|
16
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@mcp.tool(
|
|
@@ -94,9 +95,9 @@ async def canvas(
|
|
|
94
95
|
Raises:
|
|
95
96
|
ToolError: If project doesn't exist or folder path is invalid
|
|
96
97
|
"""
|
|
98
|
+
track_mcp_tool("canvas")
|
|
97
99
|
async with get_client() as client:
|
|
98
100
|
active_project = await get_active_project(client, project, context)
|
|
99
|
-
project_url = active_project.project_url
|
|
100
101
|
|
|
101
102
|
# Ensure path has .canvas extension
|
|
102
103
|
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
|
@@ -108,23 +109,44 @@ async def canvas(
|
|
|
108
109
|
# Convert to JSON
|
|
109
110
|
canvas_json = json.dumps(canvas_data, indent=2)
|
|
110
111
|
|
|
111
|
-
#
|
|
112
|
+
# Try to create the canvas file first (optimistic create)
|
|
112
113
|
logger.info(f"Creating canvas file: {file_path} in project {project}")
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
try:
|
|
115
|
+
response = await call_post(
|
|
116
|
+
client,
|
|
117
|
+
f"/v2/projects/{active_project.external_id}/resource",
|
|
118
|
+
json={"file_path": file_path, "content": canvas_json},
|
|
119
|
+
)
|
|
120
|
+
action = "Created"
|
|
121
|
+
except Exception as e:
|
|
122
|
+
# If creation failed due to conflict (already exists), try to update
|
|
123
|
+
if (
|
|
124
|
+
"409" in str(e)
|
|
125
|
+
or "conflict" in str(e).lower()
|
|
126
|
+
or "already exists" in str(e).lower()
|
|
127
|
+
):
|
|
128
|
+
logger.info(f"Canvas file exists, updating instead: {file_path}")
|
|
129
|
+
try:
|
|
130
|
+
entity_id = await resolve_entity_id(client, active_project.external_id, file_path)
|
|
131
|
+
# For update, send content in JSON body
|
|
132
|
+
response = await call_put(
|
|
133
|
+
client,
|
|
134
|
+
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
|
135
|
+
json={"content": canvas_json},
|
|
136
|
+
)
|
|
137
|
+
action = "Updated"
|
|
138
|
+
except Exception as update_error: # pragma: no cover
|
|
139
|
+
# Re-raise the original error if update also fails
|
|
140
|
+
raise e from update_error # pragma: no cover
|
|
141
|
+
else:
|
|
142
|
+
# Re-raise if it's not a conflict error
|
|
143
|
+
raise # pragma: no cover
|
|
121
144
|
|
|
122
145
|
# Parse response
|
|
123
146
|
result = response.json()
|
|
124
147
|
logger.debug(result)
|
|
125
148
|
|
|
126
149
|
# Build summary
|
|
127
|
-
action = "Created" if response.status_code == 201 else "Updated"
|
|
128
150
|
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
|
|
129
151
|
|
|
130
152
|
return "\n".join(summary)
|
|
@@ -15,6 +15,7 @@ from basic_memory.mcp.tools.search import search_notes
|
|
|
15
15
|
from basic_memory.mcp.tools.read_note import read_note
|
|
16
16
|
from basic_memory.schemas.search import SearchResponse
|
|
17
17
|
from basic_memory.config import ConfigManager
|
|
18
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
|
|
@@ -55,7 +56,7 @@ def _format_document_for_chatgpt(
|
|
|
55
56
|
title = "Untitled Document"
|
|
56
57
|
|
|
57
58
|
# Handle error cases
|
|
58
|
-
if isinstance(content, str) and content.startswith("# Note Not Found"):
|
|
59
|
+
if isinstance(content, str) and content.lstrip().startswith("# Note Not Found"):
|
|
59
60
|
return {
|
|
60
61
|
"id": identifier,
|
|
61
62
|
"title": title or "Document Not Found",
|
|
@@ -88,6 +89,7 @@ async def search(
|
|
|
88
89
|
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
|
89
90
|
where the JSON body contains `results`, `total_count`, and echo of `query`.
|
|
90
91
|
"""
|
|
92
|
+
track_mcp_tool("search")
|
|
91
93
|
logger.info(f"ChatGPT search request: query='{query}'")
|
|
92
94
|
|
|
93
95
|
try:
|
|
@@ -151,6 +153,7 @@ async def fetch(
|
|
|
151
153
|
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
|
152
154
|
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
|
|
153
155
|
"""
|
|
156
|
+
track_mcp_tool("fetch")
|
|
154
157
|
logger.info(f"ChatGPT fetch request: id='{id}'")
|
|
155
158
|
|
|
156
159
|
try:
|
|
@@ -3,12 +3,12 @@ from typing import Optional
|
|
|
3
3
|
|
|
4
4
|
from loguru import logger
|
|
5
5
|
from fastmcp import Context
|
|
6
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
6
7
|
|
|
7
8
|
from basic_memory.mcp.project_context import get_active_project
|
|
8
|
-
from basic_memory.mcp.tools.utils import call_delete
|
|
9
9
|
from basic_memory.mcp.server import mcp
|
|
10
10
|
from basic_memory.mcp.async_client import get_client
|
|
11
|
-
from basic_memory.
|
|
11
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
|
|
@@ -202,13 +202,35 @@ async def delete_note(
|
|
|
202
202
|
with suggestions for finding the correct identifier, including search
|
|
203
203
|
commands and alternative formats to try.
|
|
204
204
|
"""
|
|
205
|
+
track_mcp_tool("delete_note")
|
|
205
206
|
async with get_client() as client:
|
|
206
207
|
active_project = await get_active_project(client, project, context)
|
|
207
|
-
|
|
208
|
+
|
|
209
|
+
# Import here to avoid circular import
|
|
210
|
+
from basic_memory.mcp.clients import KnowledgeClient
|
|
211
|
+
|
|
212
|
+
# Use typed KnowledgeClient for API calls
|
|
213
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
208
214
|
|
|
209
215
|
try:
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
# Resolve identifier to entity ID
|
|
217
|
+
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
218
|
+
except ToolError as e:
|
|
219
|
+
# If entity not found, return False (note doesn't exist)
|
|
220
|
+
if "Entity not found" in str(e) or "not found" in str(e).lower():
|
|
221
|
+
logger.warning(f"Note not found for deletion: {identifier}")
|
|
222
|
+
return False
|
|
223
|
+
# For other resolution errors, return formatted error message
|
|
224
|
+
logger.error( # pragma: no cover
|
|
225
|
+
f"Delete failed for '{identifier}': {e}, project: {active_project.name}"
|
|
226
|
+
)
|
|
227
|
+
return _format_delete_error_response( # pragma: no cover
|
|
228
|
+
active_project.name, str(e), identifier
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Call the DELETE endpoint
|
|
233
|
+
result = await knowledge_client.delete_entity(entity_id)
|
|
212
234
|
|
|
213
235
|
if result.deleted:
|
|
214
236
|
logger.info(
|
|
@@ -216,8 +238,10 @@ async def delete_note(
|
|
|
216
238
|
)
|
|
217
239
|
return True
|
|
218
240
|
else:
|
|
219
|
-
logger.warning(
|
|
220
|
-
|
|
241
|
+
logger.warning( # pragma: no cover
|
|
242
|
+
f"Delete operation completed but note was not deleted: {identifier}"
|
|
243
|
+
)
|
|
244
|
+
return False # pragma: no cover
|
|
221
245
|
|
|
222
246
|
except Exception as e: # pragma: no cover
|
|
223
247
|
logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
|
|
@@ -8,8 +8,7 @@ from fastmcp import Context
|
|
|
8
8
|
from basic_memory.mcp.async_client import get_client
|
|
9
9
|
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
|
|
10
10
|
from basic_memory.mcp.server import mcp
|
|
11
|
-
from basic_memory.
|
|
12
|
-
from basic_memory.schemas import EntityResponse
|
|
11
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
def _format_error_response(
|
|
@@ -214,9 +213,9 @@ async def edit_note(
|
|
|
214
213
|
search_notes() first to find the correct identifier. The tool provides detailed
|
|
215
214
|
error messages with suggestions if operations fail.
|
|
216
215
|
"""
|
|
216
|
+
track_mcp_tool("edit_note")
|
|
217
217
|
async with get_client() as client:
|
|
218
218
|
active_project = await get_active_project(client, project, context)
|
|
219
|
-
project_url = active_project.project_url
|
|
220
219
|
|
|
221
220
|
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
|
222
221
|
|
|
@@ -235,6 +234,15 @@ async def edit_note(
|
|
|
235
234
|
|
|
236
235
|
# Use the PATCH endpoint to edit the entity
|
|
237
236
|
try:
|
|
237
|
+
# Import here to avoid circular import
|
|
238
|
+
from basic_memory.mcp.clients import KnowledgeClient
|
|
239
|
+
|
|
240
|
+
# Use typed KnowledgeClient for API calls
|
|
241
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
242
|
+
|
|
243
|
+
# Resolve identifier to entity ID
|
|
244
|
+
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
245
|
+
|
|
238
246
|
# Prepare the edit request data
|
|
239
247
|
edit_data = {
|
|
240
248
|
"operation": operation,
|
|
@@ -250,9 +258,7 @@ async def edit_note(
|
|
|
250
258
|
edit_data["expected_replacements"] = str(expected_replacements)
|
|
251
259
|
|
|
252
260
|
# Call the PATCH endpoint
|
|
253
|
-
|
|
254
|
-
response = await call_patch(client, url, json=edit_data)
|
|
255
|
-
result = EntityResponse.model_validate(response.json())
|
|
261
|
+
result = await knowledge_client.patch_entity(entity_id, edit_data)
|
|
256
262
|
|
|
257
263
|
# Format summary
|
|
258
264
|
summary = [
|
|
@@ -307,11 +313,10 @@ async def edit_note(
|
|
|
307
313
|
permalink=result.permalink,
|
|
308
314
|
observations_count=len(result.observations),
|
|
309
315
|
relations_count=len(result.relations),
|
|
310
|
-
status_code=response.status_code,
|
|
311
316
|
)
|
|
312
317
|
|
|
313
|
-
|
|
314
|
-
return add_project_metadata(
|
|
318
|
+
summary_result = "\n".join(summary)
|
|
319
|
+
return add_project_metadata(summary_result, active_project.name)
|
|
315
320
|
|
|
316
321
|
except Exception as e:
|
|
317
322
|
logger.error(f"Error editing note: {e}")
|
|
@@ -8,7 +8,7 @@ from fastmcp import Context
|
|
|
8
8
|
from basic_memory.mcp.async_client import get_client
|
|
9
9
|
from basic_memory.mcp.project_context import get_active_project
|
|
10
10
|
from basic_memory.mcp.server import mcp
|
|
11
|
-
from basic_memory.
|
|
11
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@mcp.tool(
|
|
@@ -63,30 +63,20 @@ async def list_directory(
|
|
|
63
63
|
Raises:
|
|
64
64
|
ToolError: If project doesn't exist or directory path is invalid
|
|
65
65
|
"""
|
|
66
|
+
track_mcp_tool("list_directory")
|
|
66
67
|
async with get_client() as client:
|
|
67
68
|
active_project = await get_active_project(client, project, context)
|
|
68
|
-
project_url = active_project.project_url
|
|
69
|
-
|
|
70
|
-
# Prepare query parameters
|
|
71
|
-
params = {
|
|
72
|
-
"dir_name": dir_name,
|
|
73
|
-
"depth": str(depth),
|
|
74
|
-
}
|
|
75
|
-
if file_name_glob:
|
|
76
|
-
params["file_name_glob"] = file_name_glob
|
|
77
69
|
|
|
78
70
|
logger.debug(
|
|
79
71
|
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
|
|
80
72
|
)
|
|
81
73
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
client,
|
|
85
|
-
f"{project_url}/directory/list",
|
|
86
|
-
params=params,
|
|
87
|
-
)
|
|
74
|
+
# Import here to avoid circular import
|
|
75
|
+
from basic_memory.mcp.clients import DirectoryClient
|
|
88
76
|
|
|
89
|
-
|
|
77
|
+
# Use typed DirectoryClient for API calls
|
|
78
|
+
directory_client = DirectoryClient(client, active_project.external_id)
|
|
79
|
+
nodes = await directory_client.list(dir_name, depth=depth, file_name_glob=file_name_glob)
|
|
90
80
|
|
|
91
81
|
if not nodes:
|
|
92
82
|
filter_desc = ""
|
|
@@ -8,10 +8,8 @@ from fastmcp import Context
|
|
|
8
8
|
|
|
9
9
|
from basic_memory.mcp.async_client import get_client
|
|
10
10
|
from basic_memory.mcp.server import mcp
|
|
11
|
-
from basic_memory.mcp.tools.utils import call_post, call_get
|
|
12
11
|
from basic_memory.mcp.project_context import get_active_project
|
|
13
|
-
from basic_memory.
|
|
14
|
-
from basic_memory.schemas.project_info import ProjectList
|
|
12
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
15
13
|
from basic_memory.utils import validate_project_path
|
|
16
14
|
|
|
17
15
|
|
|
@@ -30,9 +28,12 @@ async def _detect_cross_project_move_attempt(
|
|
|
30
28
|
Error message with guidance if cross-project move is detected, None otherwise
|
|
31
29
|
"""
|
|
32
30
|
try:
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
# Import here to avoid circular import
|
|
32
|
+
from basic_memory.mcp.clients import ProjectClient
|
|
33
|
+
|
|
34
|
+
# Use typed ProjectClient for API calls
|
|
35
|
+
project_client = ProjectClient(client)
|
|
36
|
+
project_list = await project_client.list_projects()
|
|
36
37
|
project_names = [p.name.lower() for p in project_list.projects]
|
|
37
38
|
|
|
38
39
|
# Check if destination path contains any project names
|
|
@@ -104,11 +105,12 @@ def _format_potential_cross_project_guidance(
|
|
|
104
105
|
identifier: str, destination_path: str, current_project: str, available_projects: list[str]
|
|
105
106
|
) -> str:
|
|
106
107
|
"""Format guidance for potentially cross-project moves."""
|
|
107
|
-
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
|
|
108
|
-
if len(available_projects) > 3:
|
|
109
|
-
other_projects += f" (and {len(available_projects) - 3} others)"
|
|
108
|
+
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects # pragma: no cover
|
|
109
|
+
if len(available_projects) > 3: # pragma: no cover
|
|
110
|
+
other_projects += f" (and {len(available_projects) - 3} others)" # pragma: no cover
|
|
110
111
|
|
|
111
|
-
return
|
|
112
|
+
return ( # pragma: no cover
|
|
113
|
+
dedent(f"""
|
|
112
114
|
# Move Failed - Check Project Context
|
|
113
115
|
|
|
114
116
|
Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
|
|
@@ -139,6 +141,7 @@ def _format_potential_cross_project_guidance(
|
|
|
139
141
|
list_memory_projects()
|
|
140
142
|
```
|
|
141
143
|
""").strip()
|
|
144
|
+
)
|
|
142
145
|
|
|
143
146
|
|
|
144
147
|
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
|
|
@@ -302,9 +305,10 @@ delete_note("{identifier}")
|
|
|
302
305
|
```"""
|
|
303
306
|
|
|
304
307
|
# Generic fallback
|
|
305
|
-
return
|
|
308
|
+
return ( # pragma: no cover
|
|
309
|
+
f"""# Move Failed
|
|
306
310
|
|
|
307
|
-
Error moving '{identifier}' to '{destination_path}': {error_message}
|
|
311
|
+
Error moving '{identifier}' to '{destination_path}': {error_message} # pragma: no cover
|
|
308
312
|
|
|
309
313
|
## General troubleshooting:
|
|
310
314
|
1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
|
|
@@ -335,6 +339,7 @@ write_note("Title", content, "target-folder")
|
|
|
335
339
|
# Delete original once confirmed
|
|
336
340
|
delete_note("{identifier}")
|
|
337
341
|
```"""
|
|
342
|
+
)
|
|
338
343
|
|
|
339
344
|
|
|
340
345
|
@mcp.tool(
|
|
@@ -395,11 +400,11 @@ async def move_note(
|
|
|
395
400
|
- Re-indexes the entity for search
|
|
396
401
|
- Maintains all observations and relations
|
|
397
402
|
"""
|
|
403
|
+
track_mcp_tool("move_note")
|
|
398
404
|
async with get_client() as client:
|
|
399
405
|
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
|
400
406
|
|
|
401
407
|
active_project = await get_active_project(client, project, context)
|
|
402
|
-
project_url = active_project.project_url
|
|
403
408
|
|
|
404
409
|
# Validate destination path to prevent path traversal attacks
|
|
405
410
|
project_path = active_project.home
|
|
@@ -431,13 +436,19 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
|
|
431
436
|
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
|
432
437
|
return cross_project_error
|
|
433
438
|
|
|
439
|
+
# Import here to avoid circular import
|
|
440
|
+
from basic_memory.mcp.clients import KnowledgeClient
|
|
441
|
+
|
|
442
|
+
# Use typed KnowledgeClient for API calls
|
|
443
|
+
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
|
444
|
+
|
|
434
445
|
# Get the source entity information for extension validation
|
|
435
446
|
source_ext = "md" # Default to .md if we can't determine source extension
|
|
436
447
|
try:
|
|
448
|
+
# Resolve identifier to entity ID
|
|
449
|
+
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
437
450
|
# Fetch source entity information to get the current file extension
|
|
438
|
-
|
|
439
|
-
response = await call_get(client, url)
|
|
440
|
-
source_entity = EntityResponse.model_validate(response.json())
|
|
451
|
+
source_entity = await knowledge_client.get_entity(entity_id)
|
|
441
452
|
if "." in source_entity.file_path:
|
|
442
453
|
source_ext = source_entity.file_path.split(".")[-1]
|
|
443
454
|
except Exception as e:
|
|
@@ -467,10 +478,10 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
|
|
467
478
|
|
|
468
479
|
# Get the source entity to check its file extension
|
|
469
480
|
try:
|
|
481
|
+
# Resolve identifier to entity ID (might already be cached from above)
|
|
482
|
+
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
470
483
|
# Fetch source entity information
|
|
471
|
-
|
|
472
|
-
response = await call_get(client, url)
|
|
473
|
-
source_entity = EntityResponse.model_validate(response.json())
|
|
484
|
+
source_entity = await knowledge_client.get_entity(entity_id)
|
|
474
485
|
|
|
475
486
|
# Extract file extensions
|
|
476
487
|
source_ext = (
|
|
@@ -505,17 +516,11 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
|
|
505
516
|
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
|
506
517
|
|
|
507
518
|
try:
|
|
508
|
-
#
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
# Call the move API endpoint
|
|
516
|
-
url = f"{project_url}/knowledge/move"
|
|
517
|
-
response = await call_post(client, url, json=move_data)
|
|
518
|
-
result = EntityResponse.model_validate(response.json())
|
|
519
|
+
# Resolve identifier to entity ID for the move operation
|
|
520
|
+
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
521
|
+
|
|
522
|
+
# Call the move API using KnowledgeClient
|
|
523
|
+
result = await knowledge_client.move_entity(entity_id, destination_path)
|
|
519
524
|
|
|
520
525
|
# Build success message
|
|
521
526
|
result_lines = [
|
|
@@ -534,7 +539,6 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
|
|
534
539
|
identifier=identifier,
|
|
535
540
|
destination_path=destination_path,
|
|
536
541
|
project=active_project.name,
|
|
537
|
-
status_code=response.status_code,
|
|
538
542
|
)
|
|
539
543
|
|
|
540
544
|
return "\n".join(result_lines)
|
|
@@ -9,12 +9,8 @@ from fastmcp import Context
|
|
|
9
9
|
|
|
10
10
|
from basic_memory.mcp.async_client import get_client
|
|
11
11
|
from basic_memory.mcp.server import mcp
|
|
12
|
-
from basic_memory.
|
|
13
|
-
from basic_memory.
|
|
14
|
-
ProjectList,
|
|
15
|
-
ProjectStatusResponse,
|
|
16
|
-
ProjectInfoRequest,
|
|
17
|
-
)
|
|
12
|
+
from basic_memory.schemas.project_info import ProjectInfoRequest
|
|
13
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
18
14
|
from basic_memory.utils import generate_permalink
|
|
19
15
|
|
|
20
16
|
|
|
@@ -40,6 +36,7 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
|
|
40
36
|
Example:
|
|
41
37
|
list_memory_projects()
|
|
42
38
|
"""
|
|
39
|
+
track_mcp_tool("list_memory_projects")
|
|
43
40
|
async with get_client() as client:
|
|
44
41
|
if context: # pragma: no cover
|
|
45
42
|
await context.info("Listing all available projects")
|
|
@@ -47,9 +44,12 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
|
|
47
44
|
# Check if server is constrained to a specific project
|
|
48
45
|
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
49
46
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
# Import here to avoid circular import
|
|
48
|
+
from basic_memory.mcp.clients import ProjectClient
|
|
49
|
+
|
|
50
|
+
# Use typed ProjectClient for API calls
|
|
51
|
+
project_client = ProjectClient(client)
|
|
52
|
+
project_list = await project_client.list_projects()
|
|
53
53
|
|
|
54
54
|
if constrained_project:
|
|
55
55
|
result = f"Project: {constrained_project}\n\n"
|
|
@@ -92,6 +92,7 @@ async def create_memory_project(
|
|
|
92
92
|
create_memory_project("my-research", "~/Documents/research")
|
|
93
93
|
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
|
94
94
|
"""
|
|
95
|
+
track_mcp_tool("create_memory_project")
|
|
95
96
|
async with get_client() as client:
|
|
96
97
|
# Check if server is constrained to a specific project
|
|
97
98
|
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
@@ -106,9 +107,12 @@ async def create_memory_project(
|
|
|
106
107
|
name=project_name, path=project_path, set_default=set_default
|
|
107
108
|
)
|
|
108
109
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
# Import here to avoid circular import
|
|
111
|
+
from basic_memory.mcp.clients import ProjectClient
|
|
112
|
+
|
|
113
|
+
# Use typed ProjectClient for API calls
|
|
114
|
+
project_client = ProjectClient(client)
|
|
115
|
+
status_response = await project_client.create_project(project_request.model_dump())
|
|
112
116
|
|
|
113
117
|
result = f"✓ {status_response.message}\n\n"
|
|
114
118
|
|
|
@@ -147,6 +151,7 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
|
|
147
151
|
This action cannot be undone. The project will need to be re-added
|
|
148
152
|
to access its content through Basic Memory again.
|
|
149
153
|
"""
|
|
154
|
+
track_mcp_tool("delete_project")
|
|
150
155
|
async with get_client() as client:
|
|
151
156
|
# Check if server is constrained to a specific project
|
|
152
157
|
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
@@ -156,11 +161,18 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
|
|
156
161
|
if context: # pragma: no cover
|
|
157
162
|
await context.info(f"Deleting project: {project_name}")
|
|
158
163
|
|
|
164
|
+
# Import here to avoid circular import
|
|
165
|
+
from basic_memory.mcp.clients import ProjectClient
|
|
166
|
+
|
|
167
|
+
# Use typed ProjectClient for API calls
|
|
168
|
+
project_client = ProjectClient(client)
|
|
169
|
+
|
|
159
170
|
# Get project info before deletion to validate it exists
|
|
160
|
-
|
|
161
|
-
project_list = ProjectList.model_validate(response.json())
|
|
171
|
+
project_list = await project_client.list_projects()
|
|
162
172
|
|
|
163
|
-
# Find the project by
|
|
173
|
+
# Find the project by permalink (derived from name).
|
|
174
|
+
# Note: The API response uses `ProjectItem` which derives `permalink` from `name`,
|
|
175
|
+
# so a separate case-insensitive name match would be redundant here.
|
|
164
176
|
project_permalink = generate_permalink(project_name)
|
|
165
177
|
target_project = None
|
|
166
178
|
for p in project_list.projects:
|
|
@@ -168,10 +180,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
|
|
168
180
|
if p.permalink == project_permalink:
|
|
169
181
|
target_project = p
|
|
170
182
|
break
|
|
171
|
-
# Also match by name comparison (case-insensitive)
|
|
172
|
-
if p.name.lower() == project_name.lower():
|
|
173
|
-
target_project = p
|
|
174
|
-
break
|
|
175
183
|
|
|
176
184
|
if not target_project:
|
|
177
185
|
available_projects = [p.name for p in project_list.projects]
|
|
@@ -179,12 +187,8 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
|
|
179
187
|
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
180
188
|
)
|
|
181
189
|
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
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())
|
|
190
|
+
# Delete project using project external_id
|
|
191
|
+
status_response = await project_client.delete_project(target_project.external_id)
|
|
188
192
|
|
|
189
193
|
result = f"✓ {status_response.message}\n\n"
|
|
190
194
|
|
|
@@ -13,12 +13,14 @@ from typing import Optional
|
|
|
13
13
|
from loguru import logger
|
|
14
14
|
from PIL import Image as PILImage
|
|
15
15
|
from fastmcp import Context
|
|
16
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
16
17
|
|
|
17
18
|
from basic_memory.mcp.project_context import get_active_project
|
|
18
19
|
from basic_memory.mcp.server import mcp
|
|
19
20
|
from basic_memory.mcp.async_client import get_client
|
|
20
|
-
from basic_memory.mcp.tools.utils import call_get
|
|
21
|
+
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
|
|
21
22
|
from basic_memory.schemas.memory import memory_url_path
|
|
23
|
+
from basic_memory.telemetry import track_mcp_tool
|
|
22
24
|
from basic_memory.utils import validate_project_path
|
|
23
25
|
|
|
24
26
|
|
|
@@ -199,11 +201,11 @@ async def read_content(
|
|
|
199
201
|
HTTPError: If project doesn't exist or is inaccessible
|
|
200
202
|
SecurityError: If path attempts path traversal
|
|
201
203
|
"""
|
|
204
|
+
track_mcp_tool("read_content")
|
|
202
205
|
logger.info("Reading file", path=path, project=project)
|
|
203
206
|
|
|
204
207
|
async with get_client() as client:
|
|
205
208
|
active_project = await get_active_project(client, project, context)
|
|
206
|
-
project_url = active_project.project_url
|
|
207
209
|
|
|
208
210
|
url = memory_url_path(path)
|
|
209
211
|
|
|
@@ -221,7 +223,15 @@ async def read_content(
|
|
|
221
223
|
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
222
224
|
}
|
|
223
225
|
|
|
224
|
-
|
|
226
|
+
# Resolve path to entity ID
|
|
227
|
+
try:
|
|
228
|
+
entity_id = await resolve_entity_id(client, active_project.external_id, url)
|
|
229
|
+
except ToolError:
|
|
230
|
+
# Convert resolution errors to "Resource not found" for consistency
|
|
231
|
+
raise ToolError(f"Resource not found: {url}")
|
|
232
|
+
|
|
233
|
+
# Call the v2 resource endpoint
|
|
234
|
+
response = await call_get(client, f"/v2/projects/{active_project.external_id}/resource/{entity_id}")
|
|
225
235
|
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
226
236
|
content_length = int(response.headers.get("content-length", 0))
|
|
227
237
|
|