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.
- basic_memory/__init__.py +2 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +28 -9
- basic_memory/mcp/tools/recent_activity.py +47 -16
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +67 -17
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.2.dist-info/RECORD +0 -100
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
"""
|
basic_memory/mcp/tools/utils.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}**""")
|