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,254 @@
1
+ """Sync status tool for Basic Memory MCP server."""
2
+
3
+ from typing import Optional
4
+
5
+ from loguru import logger
6
+
7
+ from basic_memory.mcp.server import mcp
8
+ from basic_memory.mcp.project_session import get_active_project
9
+
10
+
11
+ def _get_all_projects_status() -> list[str]:
12
+ """Get status lines for all configured projects."""
13
+ status_lines = []
14
+
15
+ try:
16
+ from basic_memory.config import app_config
17
+ from basic_memory.services.sync_status_service import sync_status_tracker
18
+
19
+ if app_config.projects:
20
+ status_lines.extend(["", "---", "", "**All Projects Status:**"])
21
+
22
+ for project_name, project_path in app_config.projects.items():
23
+ # Check if this project has sync status
24
+ project_sync_status = sync_status_tracker.get_project_status(project_name)
25
+
26
+ if project_sync_status:
27
+ # Project has tracked sync activity
28
+ if project_sync_status.status.value == "watching":
29
+ # Project is actively watching for changes (steady state)
30
+ status_icon = "👁️"
31
+ status_text = "Watching for changes"
32
+ elif project_sync_status.status.value == "completed":
33
+ # Sync completed but not yet watching - transitional state
34
+ status_icon = "✅"
35
+ status_text = "Sync completed"
36
+ elif project_sync_status.status.value in ["scanning", "syncing"]:
37
+ status_icon = "🔄"
38
+ status_text = "Sync in progress"
39
+ if project_sync_status.files_total > 0:
40
+ progress_pct = (
41
+ project_sync_status.files_processed
42
+ / project_sync_status.files_total
43
+ ) * 100
44
+ status_text += f" ({project_sync_status.files_processed}/{project_sync_status.files_total}, {progress_pct:.0f}%)"
45
+ elif project_sync_status.status.value == "failed":
46
+ status_icon = "❌"
47
+ status_text = f"Sync error: {project_sync_status.error or 'Unknown error'}"
48
+ else:
49
+ status_icon = "⏸️"
50
+ status_text = project_sync_status.status.value.title()
51
+ else:
52
+ # Project has no tracked sync activity - will be synced automatically
53
+ status_icon = "⏳"
54
+ status_text = "Pending sync"
55
+
56
+ status_lines.append(f"- {status_icon} **{project_name}**: {status_text}")
57
+
58
+ except Exception as e:
59
+ logger.debug(f"Could not get project config for comprehensive status: {e}")
60
+
61
+ return status_lines
62
+
63
+
64
+ @mcp.tool(
65
+ description="""Check the status of file synchronization and background operations.
66
+
67
+ Use this tool to:
68
+ - Check if file sync is in progress or completed
69
+ - Get detailed sync progress information
70
+ - Understand if your files are fully indexed
71
+ - Get specific error details if sync operations failed
72
+ - Monitor initial project setup and legacy migration
73
+
74
+ This covers all sync operations including:
75
+ - Initial project setup and file indexing
76
+ - Legacy project migration to unified database
77
+ - Ongoing file monitoring and updates
78
+ - Background processing of knowledge graphs
79
+ """,
80
+ )
81
+ async def sync_status(project: Optional[str] = None) -> str:
82
+ """Get current sync status and system readiness information.
83
+
84
+ This tool provides detailed information about any ongoing or completed
85
+ sync operations, helping users understand when their files are ready.
86
+
87
+ Args:
88
+ project: Optional project name to get project-specific context
89
+
90
+ Returns:
91
+ Formatted sync status with progress, readiness, and guidance
92
+ """
93
+ logger.info("MCP tool call tool=sync_status")
94
+
95
+ status_lines = []
96
+
97
+ try:
98
+ from basic_memory.services.sync_status_service import sync_status_tracker
99
+
100
+ # Get overall summary
101
+ summary = sync_status_tracker.get_summary()
102
+ is_ready = sync_status_tracker.is_ready
103
+
104
+ # Header
105
+ status_lines.extend(
106
+ [
107
+ "# Basic Memory Sync Status",
108
+ "",
109
+ f"**Current Status**: {summary}",
110
+ f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
111
+ "",
112
+ ]
113
+ )
114
+
115
+ if is_ready:
116
+ status_lines.extend(
117
+ [
118
+ "✅ **All sync operations completed**",
119
+ "",
120
+ "- File indexing is complete",
121
+ "- Knowledge graphs are up to date",
122
+ "- All Basic Memory tools are fully operational",
123
+ "",
124
+ "Your knowledge base is ready for use!",
125
+ ]
126
+ )
127
+
128
+ # Show all projects status even when ready
129
+ status_lines.extend(_get_all_projects_status())
130
+ else:
131
+ # System is still processing - show both active and all projects
132
+ all_sync_projects = sync_status_tracker.get_all_projects()
133
+
134
+ active_projects = [
135
+ p for p in all_sync_projects.values() if p.status.value in ["scanning", "syncing"]
136
+ ]
137
+ failed_projects = [p for p in all_sync_projects.values() if p.status.value == "failed"]
138
+
139
+ if active_projects:
140
+ status_lines.extend(
141
+ [
142
+ "🔄 **File synchronization in progress**",
143
+ "",
144
+ "Basic Memory is automatically processing all configured projects and building knowledge graphs.",
145
+ "This typically takes 1-3 minutes depending on the amount of content.",
146
+ "",
147
+ "**Currently Processing:**",
148
+ ]
149
+ )
150
+
151
+ for project_status in active_projects:
152
+ progress = ""
153
+ if project_status.files_total > 0:
154
+ progress_pct = (
155
+ project_status.files_processed / project_status.files_total
156
+ ) * 100
157
+ progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
158
+
159
+ status_lines.append(
160
+ f"- **{project_status.project_name}**: {project_status.message}{progress}"
161
+ )
162
+
163
+ status_lines.extend(
164
+ [
165
+ "",
166
+ "**What's happening:**",
167
+ "- Scanning and indexing markdown files",
168
+ "- Building entity and relationship graphs",
169
+ "- Setting up full-text search indexes",
170
+ "- Processing file changes and updates",
171
+ "",
172
+ "**What you can do:**",
173
+ "- Wait for automatic processing to complete - no action needed",
174
+ "- Use this tool again to check progress",
175
+ "- Simple operations may work already",
176
+ "- All projects will be available once sync finishes",
177
+ ]
178
+ )
179
+
180
+ # Handle failed projects (independent of active projects)
181
+ if failed_projects:
182
+ status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
183
+
184
+ for project_status in failed_projects:
185
+ status_lines.append(
186
+ f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
187
+ )
188
+
189
+ status_lines.extend(
190
+ [
191
+ "",
192
+ "**Next steps:**",
193
+ "1. Check the logs for detailed error information",
194
+ "2. Ensure file permissions allow read/write access",
195
+ "3. Try restarting the MCP server",
196
+ "4. If issues persist, consider filing a support issue",
197
+ ]
198
+ )
199
+ elif not active_projects:
200
+ # No active or failed projects - must be pending
201
+ status_lines.extend(
202
+ [
203
+ "⏳ **Sync operations pending**",
204
+ "",
205
+ "File synchronization has been queued but hasn't started yet.",
206
+ "This usually resolves automatically within a few seconds.",
207
+ ]
208
+ )
209
+
210
+ # Add comprehensive project status for all configured projects
211
+ all_projects_status = _get_all_projects_status()
212
+ if all_projects_status:
213
+ status_lines.extend(all_projects_status)
214
+
215
+ # Add explanation about automatic syncing if there are unsynced projects
216
+ unsynced_count = sum(1 for line in all_projects_status if "⏳" in line)
217
+ if unsynced_count > 0 and not is_ready:
218
+ status_lines.extend(
219
+ [
220
+ "",
221
+ "**Note**: All configured projects will be automatically synced during startup.",
222
+ "You don't need to manually switch projects - Basic Memory handles this for you.",
223
+ ]
224
+ )
225
+
226
+ # Add project context if provided
227
+ if project:
228
+ try:
229
+ active_project = get_active_project(project)
230
+ status_lines.extend(
231
+ [
232
+ "",
233
+ "---",
234
+ "",
235
+ f"**Active Project**: {active_project.name}",
236
+ f"**Project Path**: {active_project.home}",
237
+ ]
238
+ )
239
+ except Exception as e:
240
+ logger.debug(f"Could not get project info: {e}")
241
+
242
+ return "\n".join(status_lines)
243
+
244
+ except Exception as e:
245
+ return f"""# Sync Status - Error
246
+
247
+ ❌ **Unable to check sync status**: {str(e)}
248
+
249
+ **Troubleshooting:**
250
+ - The system may still be starting up
251
+ - Try waiting a few seconds and checking again
252
+ - Check logs for detailed error information
253
+ - Consider restarting if the issue persists
254
+ """
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
5
5
  """
6
6
 
7
7
  import typing
8
+ from typing import Optional
8
9
 
9
10
  from httpx import Response, URL, AsyncClient, HTTPStatusError
10
11
  from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -23,7 +24,9 @@ from loguru import logger
23
24
  from mcp.server.fastmcp.exceptions import ToolError
24
25
 
25
26
 
26
- def get_error_message(status_code: int, url: URL | str, method: str) -> str:
27
+ def get_error_message(
28
+ status_code: int, url: URL | str, method: str, msg: Optional[str] = None
29
+ ) -> str:
27
30
  """Get a friendly error message based on the HTTP status code.
28
31
 
29
32
  Args:
@@ -103,6 +106,7 @@ async def call_get(
103
106
  ToolError: If the request fails with an appropriate error message
104
107
  """
105
108
  logger.debug(f"Calling GET '{url}' params: '{params}'")
109
+ error_message = None
106
110
  try:
107
111
  response = await client.get(
108
112
  url,
@@ -120,7 +124,12 @@ async def call_get(
120
124
 
121
125
  # Handle different status codes differently
122
126
  status_code = response.status_code
123
- error_message = get_error_message(status_code, url, "GET")
127
+ # get the message if available
128
+ response_data = response.json()
129
+ if isinstance(response_data, dict) and "detail" in response_data:
130
+ error_message = response_data["detail"]
131
+ else:
132
+ error_message = get_error_message(status_code, url, "PUT")
124
133
 
125
134
  # Log at appropriate level based on status code
126
135
  if 400 <= status_code < 500:
@@ -138,8 +147,6 @@ async def call_get(
138
147
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
139
148
 
140
149
  except HTTPStatusError as e:
141
- status_code = e.response.status_code
142
- error_message = get_error_message(status_code, url, "GET")
143
150
  raise ToolError(error_message) from e
144
151
 
145
152
 
@@ -183,6 +190,8 @@ async def call_put(
183
190
  ToolError: If the request fails with an appropriate error message
184
191
  """
185
192
  logger.debug(f"Calling PUT '{url}'")
193
+ error_message = None
194
+
186
195
  try:
187
196
  response = await client.put(
188
197
  url,
@@ -204,7 +213,13 @@ async def call_put(
204
213
 
205
214
  # Handle different status codes differently
206
215
  status_code = response.status_code
207
- error_message = get_error_message(status_code, url, "PUT")
216
+
217
+ # get the message if available
218
+ response_data = response.json()
219
+ if isinstance(response_data, dict) and "detail" in response_data:
220
+ error_message = response_data["detail"] # pragma: no cover
221
+ else:
222
+ error_message = get_error_message(status_code, url, "PUT")
208
223
 
209
224
  # Log at appropriate level based on status code
210
225
  if 400 <= status_code < 500:
@@ -221,9 +236,110 @@ async def call_put(
221
236
  response.raise_for_status() # Will always raise since we're in the error case
222
237
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
223
238
 
239
+ except HTTPStatusError as e:
240
+ raise ToolError(error_message) from e
241
+
242
+
243
+ async def call_patch(
244
+ client: AsyncClient,
245
+ url: URL | str,
246
+ *,
247
+ content: RequestContent | None = None,
248
+ data: RequestData | None = None,
249
+ files: RequestFiles | None = None,
250
+ json: typing.Any | None = None,
251
+ params: QueryParamTypes | None = None,
252
+ headers: HeaderTypes | None = None,
253
+ cookies: CookieTypes | None = None,
254
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
255
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
256
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
257
+ extensions: RequestExtensions | None = None,
258
+ ) -> Response:
259
+ """Make a PATCH request and handle errors appropriately.
260
+
261
+ Args:
262
+ client: The HTTPX AsyncClient to use
263
+ url: The URL to request
264
+ content: Request content
265
+ data: Form data
266
+ files: Files to upload
267
+ json: JSON data
268
+ params: Query parameters
269
+ headers: HTTP headers
270
+ cookies: HTTP cookies
271
+ auth: Authentication
272
+ follow_redirects: Whether to follow redirects
273
+ timeout: Request timeout
274
+ extensions: HTTPX extensions
275
+
276
+ Returns:
277
+ The HTTP response
278
+
279
+ Raises:
280
+ ToolError: If the request fails with an appropriate error message
281
+ """
282
+ logger.debug(f"Calling PATCH '{url}'")
283
+ try:
284
+ response = await client.patch(
285
+ url,
286
+ content=content,
287
+ data=data,
288
+ files=files,
289
+ json=json,
290
+ params=params,
291
+ headers=headers,
292
+ cookies=cookies,
293
+ auth=auth,
294
+ follow_redirects=follow_redirects,
295
+ timeout=timeout,
296
+ extensions=extensions,
297
+ )
298
+
299
+ if response.is_success:
300
+ return response
301
+
302
+ # Handle different status codes differently
303
+ status_code = response.status_code
304
+
305
+ # Try to extract specific error message from response body
306
+ try:
307
+ response_data = response.json()
308
+ if isinstance(response_data, dict) and "detail" in response_data:
309
+ error_message = response_data["detail"]
310
+ else:
311
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
312
+ except Exception: # pragma: no cover
313
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
314
+
315
+ # Log at appropriate level based on status code
316
+ if 400 <= status_code < 500:
317
+ # Client errors: log as info except for 429 (Too Many Requests)
318
+ if status_code == 429: # pragma: no cover
319
+ logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
320
+ else:
321
+ logger.info(f"Client error: PATCH {url}: {error_message}")
322
+ else: # pragma: no cover
323
+ # Server errors: log as error
324
+ logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
325
+
326
+ # Raise a tool error with the friendly message
327
+ response.raise_for_status() # Will always raise since we're in the error case
328
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
329
+
224
330
  except HTTPStatusError as e:
225
331
  status_code = e.response.status_code
226
- error_message = get_error_message(status_code, url, "PUT")
332
+
333
+ # Try to extract specific error message from response body
334
+ try:
335
+ response_data = e.response.json()
336
+ if isinstance(response_data, dict) and "detail" in response_data:
337
+ error_message = response_data["detail"]
338
+ else:
339
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
340
+ except Exception: # pragma: no cover
341
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
342
+
227
343
  raise ToolError(error_message) from e
228
344
 
229
345
 
@@ -267,6 +383,7 @@ async def call_post(
267
383
  ToolError: If the request fails with an appropriate error message
268
384
  """
269
385
  logger.debug(f"Calling POST '{url}'")
386
+ error_message = None
270
387
  try:
271
388
  response = await client.post(
272
389
  url=url,
@@ -282,13 +399,19 @@ async def call_post(
282
399
  timeout=timeout,
283
400
  extensions=extensions,
284
401
  )
402
+ logger.debug(f"response: {response.json()}")
285
403
 
286
404
  if response.is_success:
287
405
  return response
288
406
 
289
407
  # Handle different status codes differently
290
408
  status_code = response.status_code
291
- error_message = get_error_message(status_code, url, "POST")
409
+ # get the message if available
410
+ response_data = response.json()
411
+ if isinstance(response_data, dict) and "detail" in response_data:
412
+ error_message = response_data["detail"]
413
+ else:
414
+ error_message = get_error_message(status_code, url, "POST")
292
415
 
293
416
  # Log at appropriate level based on status code
294
417
  if 400 <= status_code < 500:
@@ -306,8 +429,6 @@ async def call_post(
306
429
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
307
430
 
308
431
  except HTTPStatusError as e:
309
- status_code = e.response.status_code
310
- error_message = get_error_message(status_code, url, "POST")
311
432
  raise ToolError(error_message) from e
312
433
 
313
434
 
@@ -343,6 +464,7 @@ async def call_delete(
343
464
  ToolError: If the request fails with an appropriate error message
344
465
  """
345
466
  logger.debug(f"Calling DELETE '{url}'")
467
+ error_message = None
346
468
  try:
347
469
  response = await client.delete(
348
470
  url=url,
@@ -360,7 +482,12 @@ async def call_delete(
360
482
 
361
483
  # Handle different status codes differently
362
484
  status_code = response.status_code
363
- error_message = get_error_message(status_code, url, "DELETE")
485
+ # get the message if available
486
+ response_data = response.json()
487
+ if isinstance(response_data, dict) and "detail" in response_data:
488
+ error_message = response_data["detail"] # pragma: no cover
489
+ else:
490
+ error_message = get_error_message(status_code, url, "DELETE")
364
491
 
365
492
  # Log at appropriate level based on status code
366
493
  if 400 <= status_code < 500:
@@ -378,6 +505,51 @@ async def call_delete(
378
505
  return response # This line will never execute, but it satisfies the type checker # pragma: no cover
379
506
 
380
507
  except HTTPStatusError as e:
381
- status_code = e.response.status_code
382
- error_message = get_error_message(status_code, url, "DELETE")
383
508
  raise ToolError(error_message) from e
509
+
510
+
511
+ def check_migration_status() -> Optional[str]:
512
+ """Check if sync/migration is in progress and return status message if so.
513
+
514
+ Returns:
515
+ Status message if sync is in progress, None if system is ready
516
+ """
517
+ try:
518
+ from basic_memory.services.sync_status_service import sync_status_tracker
519
+
520
+ if not sync_status_tracker.is_ready:
521
+ return sync_status_tracker.get_summary()
522
+ return None
523
+ except Exception:
524
+ # If there's any error checking sync status, assume ready
525
+ return None
526
+
527
+
528
+ async def wait_for_migration_or_return_status(timeout: float = 5.0) -> Optional[str]:
529
+ """Wait briefly for sync/migration to complete, or return status message.
530
+
531
+ Args:
532
+ timeout: Maximum time to wait for sync completion
533
+
534
+ Returns:
535
+ Status message if sync is still in progress, None if ready
536
+ """
537
+ try:
538
+ from basic_memory.services.sync_status_service import sync_status_tracker
539
+ import asyncio
540
+
541
+ if sync_status_tracker.is_ready:
542
+ return None
543
+
544
+ # Wait briefly for sync to complete
545
+ start_time = asyncio.get_event_loop().time()
546
+ while (asyncio.get_event_loop().time() - start_time) < timeout:
547
+ if sync_status_tracker.is_ready:
548
+ return None
549
+ await asyncio.sleep(0.1) # Check every 100ms
550
+
551
+ # Still not ready after timeout
552
+ return sync_status_tracker.get_summary()
553
+ except Exception: # pragma: no cover
554
+ # If there's any error, assume ready
555
+ return None
@@ -0,0 +1,66 @@
1
+ """View 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.server import mcp
9
+ from basic_memory.mcp.tools.read_note import read_note
10
+
11
+
12
+ @mcp.tool(
13
+ description="View a note as a formatted artifact for better readability.",
14
+ )
15
+ async def view_note(
16
+ identifier: str, page: int = 1, page_size: int = 10, project: Optional[str] = None
17
+ ) -> str:
18
+ """View a markdown note as a formatted artifact.
19
+
20
+ This tool reads a note using the same logic as read_note but displays the content
21
+ as a markdown artifact for better viewing experience in Claude Desktop.
22
+
23
+ After calling this tool, create an artifact using the returned content to display
24
+ the note in a readable format. The tool returns the note content that should be
25
+ used to create a markdown artifact.
26
+
27
+ Args:
28
+ identifier: The title or permalink of the note to view
29
+ page: Page number for paginated results (default: 1)
30
+ page_size: Number of items per page (default: 10)
31
+ project: Optional project name to read from. If not provided, uses current active project.
32
+
33
+ Returns:
34
+ The note content as a markdown artifact with a confirmation message.
35
+ """
36
+
37
+ logger.info(f"Viewing note: {identifier}")
38
+
39
+ # Call the existing read_note logic
40
+ content = await read_note.fn(identifier, page, page_size, project)
41
+
42
+ # Check if this is an error message (note not found)
43
+ if "# Note Not Found:" in content:
44
+ return content # Return error message directly instead of creating artifact
45
+
46
+ # Extract title from content if possible
47
+ title = identifier
48
+ lines = content.split("\n")
49
+ for line in lines:
50
+ if line.startswith("title:"):
51
+ # Extract title from frontmatter
52
+ title = line.split("title:", 1)[1].strip().strip('"').strip("'")
53
+ break
54
+ elif line.startswith("# "):
55
+ # Extract title from first heading
56
+ title = line[2:].strip()
57
+ break
58
+
59
+ # Return the artifact XML as part of the response
60
+ artifact = f'<artifact identifier="note-{hash(identifier) & 0x7FFFFFFF}" type="text/markdown" title="{title}">\n{content}\n</artifact>'
61
+
62
+ return dedent(f"""
63
+ <instructions>
64
+ Create an artifact using the returned artifact content to display the note in a readable format.
65
+ </instructions>
66
+ {artifact}\n\n✅ Note displayed as artifact: **{title}**""")