basic-memory 0.12.3__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 +26 -7
- basic_memory/mcp/tools/recent_activity.py +11 -2
- 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-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
- 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.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
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}**""")
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Write note tool for Basic Memory MCP server."""
|
|
2
2
|
|
|
3
|
-
from typing import List, Union
|
|
3
|
+
from typing import List, Union, Optional
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
7
|
from basic_memory.mcp.async_client import client
|
|
8
8
|
from basic_memory.mcp.server import mcp
|
|
9
9
|
from basic_memory.mcp.tools.utils import call_put
|
|
10
|
+
from basic_memory.mcp.project_session import get_active_project
|
|
10
11
|
from basic_memory.schemas import EntityResponse
|
|
11
12
|
from basic_memory.schemas.base import Entity
|
|
12
13
|
from basic_memory.utils import parse_tags
|
|
@@ -26,6 +27,7 @@ async def write_note(
|
|
|
26
27
|
content: str,
|
|
27
28
|
folder: str,
|
|
28
29
|
tags=None, # Remove type hint completely to avoid schema issues
|
|
30
|
+
project: Optional[str] = None,
|
|
29
31
|
) -> str:
|
|
30
32
|
"""Write a markdown note to the knowledge base.
|
|
31
33
|
|
|
@@ -52,9 +54,11 @@ async def write_note(
|
|
|
52
54
|
Args:
|
|
53
55
|
title: The title of the note
|
|
54
56
|
content: Markdown content for the note, can include observations and relations
|
|
55
|
-
folder:
|
|
57
|
+
folder: Folder path relative to project root where the file should be saved.
|
|
58
|
+
Use forward slashes (/) as separators. Examples: "notes", "projects/2025", "research/ml"
|
|
56
59
|
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
|
|
57
60
|
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
|
|
61
|
+
project: Optional project name to write to. If not provided, uses current active project.
|
|
58
62
|
|
|
59
63
|
Returns:
|
|
60
64
|
A markdown formatted summary of the semantic content, including:
|
|
@@ -64,12 +68,19 @@ async def write_note(
|
|
|
64
68
|
- Relation counts (resolved/unresolved)
|
|
65
69
|
- Tags if present
|
|
66
70
|
"""
|
|
67
|
-
logger.info("MCP tool call
|
|
71
|
+
logger.info(f"MCP tool call tool=write_note folder={folder}, title={title}, tags={tags}")
|
|
72
|
+
|
|
73
|
+
# Check migration status and wait briefly if needed
|
|
74
|
+
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
75
|
+
|
|
76
|
+
migration_status = await wait_for_migration_or_return_status(timeout=5.0)
|
|
77
|
+
if migration_status: # pragma: no cover
|
|
78
|
+
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
|
|
68
79
|
|
|
69
80
|
# Process tags using the helper function
|
|
70
81
|
tag_list = parse_tags(tags)
|
|
71
82
|
# Create the entity request
|
|
72
|
-
metadata = {"tags":
|
|
83
|
+
metadata = {"tags": tag_list} if tag_list else None
|
|
73
84
|
entity = Entity(
|
|
74
85
|
title=title,
|
|
75
86
|
folder=folder,
|
|
@@ -78,10 +89,12 @@ async def write_note(
|
|
|
78
89
|
content=content,
|
|
79
90
|
entity_metadata=metadata,
|
|
80
91
|
)
|
|
92
|
+
active_project = get_active_project(project)
|
|
93
|
+
project_url = active_project.project_url
|
|
81
94
|
|
|
82
95
|
# Create or update via knowledge API
|
|
83
|
-
logger.debug("Creating entity via API
|
|
84
|
-
url = f"/knowledge/entities/{entity.permalink}"
|
|
96
|
+
logger.debug(f"Creating entity via API permalink={entity.permalink}")
|
|
97
|
+
url = f"{project_url}/knowledge/entities/{entity.permalink}"
|
|
85
98
|
response = await call_put(client, url, json=entity.model_dump())
|
|
86
99
|
result = EntityResponse.model_validate(response.json())
|
|
87
100
|
|
|
@@ -115,22 +128,16 @@ async def write_note(
|
|
|
115
128
|
summary.append(f"- Resolved: {resolved}")
|
|
116
129
|
if unresolved:
|
|
117
130
|
summary.append(f"- Unresolved: {unresolved}")
|
|
118
|
-
summary.append("\
|
|
131
|
+
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
|
|
132
|
+
summary.append(
|
|
133
|
+
"They will be automatically resolved when target entities are created or during sync operations."
|
|
134
|
+
)
|
|
119
135
|
|
|
120
136
|
if tag_list:
|
|
121
137
|
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")
|
|
122
138
|
|
|
123
139
|
# Log the response with structured data
|
|
124
140
|
logger.info(
|
|
125
|
-
"MCP tool response"
|
|
126
|
-
tool="write_note",
|
|
127
|
-
action=action,
|
|
128
|
-
permalink=result.permalink,
|
|
129
|
-
observations_count=len(result.observations),
|
|
130
|
-
relations_count=len(result.relations),
|
|
131
|
-
resolved_relations=resolved,
|
|
132
|
-
unresolved_relations=unresolved,
|
|
133
|
-
status_code=response.status_code,
|
|
141
|
+
f"MCP tool response: tool=write_note action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved} status_code={response.status_code}"
|
|
134
142
|
)
|
|
135
|
-
|
|
136
143
|
return "\n".join(summary)
|
basic_memory/models/__init__.py
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
import basic_memory
|
|
4
4
|
from basic_memory.models.base import Base
|
|
5
5
|
from basic_memory.models.knowledge import Entity, Observation, Relation
|
|
6
|
-
|
|
7
|
-
SCHEMA_VERSION = basic_memory.__version__ + "-" + "003"
|
|
6
|
+
from basic_memory.models.project import Project
|
|
8
7
|
|
|
9
8
|
__all__ = [
|
|
10
9
|
"Base",
|
|
11
10
|
"Entity",
|
|
12
11
|
"Observation",
|
|
13
12
|
"Relation",
|
|
13
|
+
"Project",
|
|
14
|
+
"basic_memory",
|
|
14
15
|
]
|
basic_memory/models/knowledge.py
CHANGED
|
@@ -17,7 +17,6 @@ from sqlalchemy import (
|
|
|
17
17
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
18
18
|
|
|
19
19
|
from basic_memory.models.base import Base
|
|
20
|
-
|
|
21
20
|
from basic_memory.utils import generate_permalink
|
|
22
21
|
|
|
23
22
|
|
|
@@ -29,6 +28,7 @@ class Entity(Base):
|
|
|
29
28
|
- Maps to a file on disk
|
|
30
29
|
- Maintains a checksum for change detection
|
|
31
30
|
- Tracks both source file and semantic properties
|
|
31
|
+
- Belongs to a specific project
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
34
|
__tablename__ = "entity"
|
|
@@ -38,13 +38,21 @@ class Entity(Base):
|
|
|
38
38
|
Index("ix_entity_title", "title"),
|
|
39
39
|
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
|
40
40
|
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
|
|
41
|
-
#
|
|
41
|
+
Index("ix_entity_project_id", "project_id"), # For project filtering
|
|
42
|
+
# Project-specific uniqueness constraints
|
|
42
43
|
Index(
|
|
43
|
-
"
|
|
44
|
+
"uix_entity_permalink_project",
|
|
44
45
|
"permalink",
|
|
46
|
+
"project_id",
|
|
45
47
|
unique=True,
|
|
46
48
|
sqlite_where=text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
|
47
49
|
),
|
|
50
|
+
Index(
|
|
51
|
+
"uix_entity_file_path_project",
|
|
52
|
+
"file_path",
|
|
53
|
+
"project_id",
|
|
54
|
+
unique=True,
|
|
55
|
+
),
|
|
48
56
|
)
|
|
49
57
|
|
|
50
58
|
# Core identity
|
|
@@ -54,10 +62,13 @@ class Entity(Base):
|
|
|
54
62
|
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
55
63
|
content_type: Mapped[str] = mapped_column(String)
|
|
56
64
|
|
|
65
|
+
# Project reference
|
|
66
|
+
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("project.id"), nullable=False)
|
|
67
|
+
|
|
57
68
|
# Normalized path for URIs - required for markdown files only
|
|
58
69
|
permalink: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
|
|
59
70
|
# Actual filesystem relative path
|
|
60
|
-
file_path: Mapped[str] = mapped_column(String,
|
|
71
|
+
file_path: Mapped[str] = mapped_column(String, index=True)
|
|
61
72
|
# checksum of file
|
|
62
73
|
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
63
74
|
|
|
@@ -66,6 +77,7 @@ class Entity(Base):
|
|
|
66
77
|
updated_at: Mapped[datetime] = mapped_column(DateTime)
|
|
67
78
|
|
|
68
79
|
# Relationships
|
|
80
|
+
project = relationship("Project", back_populates="entities")
|
|
69
81
|
observations = relationship(
|
|
70
82
|
"Observation", back_populates="entity", cascade="all, delete-orphan"
|
|
71
83
|
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Project model for Basic Memory."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
Integer,
|
|
8
|
+
String,
|
|
9
|
+
Text,
|
|
10
|
+
Boolean,
|
|
11
|
+
DateTime,
|
|
12
|
+
Index,
|
|
13
|
+
event,
|
|
14
|
+
)
|
|
15
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
16
|
+
|
|
17
|
+
from basic_memory.models.base import Base
|
|
18
|
+
from basic_memory.utils import generate_permalink
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Project(Base):
|
|
22
|
+
"""Project model for Basic Memory.
|
|
23
|
+
|
|
24
|
+
A project represents a collection of knowledge entities that are grouped together.
|
|
25
|
+
Projects are stored in the app-level database and provide context for all knowledge
|
|
26
|
+
operations.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__tablename__ = "project"
|
|
30
|
+
__table_args__ = (
|
|
31
|
+
# Regular indexes
|
|
32
|
+
Index("ix_project_name", "name", unique=True),
|
|
33
|
+
Index("ix_project_permalink", "permalink", unique=True),
|
|
34
|
+
Index("ix_project_path", "path"),
|
|
35
|
+
Index("ix_project_created_at", "created_at"),
|
|
36
|
+
Index("ix_project_updated_at", "updated_at"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Core identity
|
|
40
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
41
|
+
name: Mapped[str] = mapped_column(String, unique=True)
|
|
42
|
+
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
43
|
+
|
|
44
|
+
# URL-friendly identifier generated from name
|
|
45
|
+
permalink: Mapped[str] = mapped_column(String, unique=True)
|
|
46
|
+
|
|
47
|
+
# Filesystem path to project directory
|
|
48
|
+
path: Mapped[str] = mapped_column(String)
|
|
49
|
+
|
|
50
|
+
# Status flags
|
|
51
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
52
|
+
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
|
53
|
+
|
|
54
|
+
# Timestamps
|
|
55
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|
56
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
57
|
+
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Define relationships to entities, observations, and relations
|
|
61
|
+
# These relationships will be established once we add project_id to those models
|
|
62
|
+
entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
65
|
+
return f"Project(id={self.id}, name='{self.name}', permalink='{self.permalink}', path='{self.path}')"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@event.listens_for(Project, "before_insert")
|
|
69
|
+
@event.listens_for(Project, "before_update")
|
|
70
|
+
def set_project_permalink(mapper, connection, project):
|
|
71
|
+
"""Generate URL-friendly permalink for the project if needed.
|
|
72
|
+
|
|
73
|
+
This event listener ensures the permalink is always derived from the name,
|
|
74
|
+
even if the name changes.
|
|
75
|
+
"""
|
|
76
|
+
# If the name changed or permalink is empty, regenerate permalink
|
|
77
|
+
if not project.permalink or project.permalink != generate_permalink(project.name):
|
|
78
|
+
project.permalink = generate_permalink(project.name)
|
basic_memory/models/search.py
CHANGED
|
@@ -13,21 +13,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
|
13
13
|
permalink, -- Stable identifier (now indexed for path search)
|
|
14
14
|
file_path UNINDEXED, -- Physical location
|
|
15
15
|
type UNINDEXED, -- entity/relation/observation
|
|
16
|
-
|
|
17
|
-
--
|
|
16
|
+
|
|
17
|
+
-- Project context
|
|
18
|
+
project_id UNINDEXED, -- Project identifier
|
|
19
|
+
|
|
20
|
+
-- Relation fields
|
|
18
21
|
from_id UNINDEXED, -- Source entity
|
|
19
22
|
to_id UNINDEXED, -- Target entity
|
|
20
23
|
relation_type UNINDEXED, -- Type of relation
|
|
21
|
-
|
|
24
|
+
|
|
22
25
|
-- Observation fields
|
|
23
26
|
entity_id UNINDEXED, -- Parent entity
|
|
24
27
|
category UNINDEXED, -- Observation category
|
|
25
|
-
|
|
28
|
+
|
|
26
29
|
-- Common fields
|
|
27
30
|
metadata UNINDEXED, -- JSON metadata
|
|
28
31
|
created_at UNINDEXED, -- Creation timestamp
|
|
29
32
|
updated_at UNINDEXED, -- Last update
|
|
30
|
-
|
|
33
|
+
|
|
31
34
|
-- Configuration
|
|
32
35
|
tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
|
|
33
36
|
prefix='1,2,3,4' -- Support longer prefixes for paths
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from .entity_repository import EntityRepository
|
|
2
2
|
from .observation_repository import ObservationRepository
|
|
3
|
+
from .project_repository import ProjectRepository
|
|
3
4
|
from .relation_repository import RelationRepository
|
|
4
5
|
|
|
5
6
|
__all__ = [
|
|
6
7
|
"EntityRepository",
|
|
7
8
|
"ObservationRepository",
|
|
9
|
+
"ProjectRepository",
|
|
8
10
|
"RelationRepository",
|
|
9
11
|
]
|
|
@@ -18,9 +18,14 @@ class EntityRepository(Repository[Entity]):
|
|
|
18
18
|
to strings before passing to repository methods.
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
-
def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
|
|
22
|
-
"""Initialize with session maker.
|
|
23
|
-
|
|
21
|
+
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
|
22
|
+
"""Initialize with session maker and project_id filter.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
session_maker: SQLAlchemy session maker
|
|
26
|
+
project_id: Project ID to filter all operations by
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(session_maker, Entity, project_id=project_id)
|
|
24
29
|
|
|
25
30
|
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
|
26
31
|
"""Get entity by permalink.
|