basic-memory 0.15.0__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (47) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/routers/directory_router.py +23 -2
  3. basic_memory/api/routers/project_router.py +1 -0
  4. basic_memory/cli/auth.py +2 -2
  5. basic_memory/cli/commands/command_utils.py +11 -28
  6. basic_memory/cli/commands/mcp.py +72 -67
  7. basic_memory/cli/commands/project.py +54 -49
  8. basic_memory/cli/commands/status.py +6 -15
  9. basic_memory/config.py +55 -9
  10. basic_memory/deps.py +7 -5
  11. basic_memory/ignore_utils.py +7 -7
  12. basic_memory/mcp/async_client.py +102 -4
  13. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  14. basic_memory/mcp/prompts/search.py +12 -11
  15. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  16. basic_memory/mcp/resources/project_info.py +9 -7
  17. basic_memory/mcp/tools/build_context.py +40 -39
  18. basic_memory/mcp/tools/canvas.py +21 -20
  19. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  20. basic_memory/mcp/tools/delete_note.py +22 -21
  21. basic_memory/mcp/tools/edit_note.py +105 -104
  22. basic_memory/mcp/tools/list_directory.py +98 -95
  23. basic_memory/mcp/tools/move_note.py +127 -125
  24. basic_memory/mcp/tools/project_management.py +101 -98
  25. basic_memory/mcp/tools/read_content.py +64 -63
  26. basic_memory/mcp/tools/read_note.py +88 -88
  27. basic_memory/mcp/tools/recent_activity.py +139 -135
  28. basic_memory/mcp/tools/search.py +27 -26
  29. basic_memory/mcp/tools/sync_status.py +133 -128
  30. basic_memory/mcp/tools/utils.py +0 -15
  31. basic_memory/mcp/tools/view_note.py +14 -28
  32. basic_memory/mcp/tools/write_note.py +97 -87
  33. basic_memory/repository/entity_repository.py +60 -0
  34. basic_memory/repository/repository.py +16 -3
  35. basic_memory/repository/search_repository.py +42 -0
  36. basic_memory/schemas/project_info.py +1 -1
  37. basic_memory/services/directory_service.py +124 -3
  38. basic_memory/services/entity_service.py +31 -9
  39. basic_memory/services/project_service.py +97 -10
  40. basic_memory/services/search_service.py +16 -8
  41. basic_memory/sync/sync_service.py +28 -13
  42. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
  43. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
  44. basic_memory/mcp/tools/headers.py +0 -44
  45. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  46. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  47. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,7 @@ from typing import Optional
5
5
  from loguru import logger
6
6
  from fastmcp import Context
7
7
 
8
- from basic_memory.mcp.async_client import client
8
+ from basic_memory.mcp.async_client import get_client
9
9
  from basic_memory.mcp.project_context import get_active_project
10
10
  from basic_memory.mcp.server import mcp
11
11
  from basic_memory.mcp.tools.utils import call_get
@@ -63,102 +63,105 @@ async def list_directory(
63
63
  Raises:
64
64
  ToolError: If project doesn't exist or directory path is invalid
65
65
  """
66
- active_project = await get_active_project(client, project, context)
67
- project_url = active_project.project_url
68
-
69
- # Prepare query parameters
70
- params = {
71
- "dir_name": dir_name,
72
- "depth": str(depth),
73
- }
74
- if file_name_glob:
75
- params["file_name_glob"] = file_name_glob
76
-
77
- logger.debug(
78
- f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
79
- )
80
-
81
- # Call the API endpoint
82
- response = await call_get(
83
- client,
84
- f"{project_url}/directory/list",
85
- params=params,
86
- )
87
-
88
- nodes = response.json()
89
-
90
- if not nodes:
91
- filter_desc = ""
66
+ async with get_client() as client:
67
+ active_project = await get_active_project(client, project, context)
68
+ project_url = active_project.project_url
69
+
70
+ # Prepare query parameters
71
+ params = {
72
+ "dir_name": dir_name,
73
+ "depth": str(depth),
74
+ }
92
75
  if file_name_glob:
93
- filter_desc = f" matching '{file_name_glob}'"
94
- return f"No files found in directory '{dir_name}'{filter_desc}"
95
-
96
- # Format the results
97
- output_lines = []
98
- if file_name_glob:
99
- output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
100
- else:
101
- output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
102
- output_lines.append("")
103
-
104
- # Group by type and sort
105
- directories = [n for n in nodes if n["type"] == "directory"]
106
- files = [n for n in nodes if n["type"] == "file"]
107
-
108
- # Sort by name
109
- directories.sort(key=lambda x: x["name"])
110
- files.sort(key=lambda x: x["name"])
111
-
112
- # Display directories first
113
- for node in directories:
114
- path_display = node["directory_path"]
115
- output_lines.append(f"📁 {node['name']:<30} {path_display}")
116
-
117
- # Add separator if we have both directories and files
118
- if directories and files:
119
- output_lines.append("")
76
+ params["file_name_glob"] = file_name_glob
77
+
78
+ logger.debug(
79
+ f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
80
+ )
120
81
 
121
- # Display files with metadata
122
- for node in files:
123
- path_display = node["directory_path"]
124
- title = node.get("title", "")
125
- updated = node.get("updated_at", "")
126
-
127
- # Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
128
- if path_display.startswith("/"):
129
- path_display = path_display[1:]
130
-
131
- # Format date if available
132
- date_str = ""
133
- if updated:
134
- try:
135
- from datetime import datetime
136
-
137
- dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
138
- date_str = dt.strftime("%Y-%m-%d")
139
- except Exception: # pragma: no cover
140
- date_str = updated[:10] if len(updated) >= 10 else ""
141
-
142
- # Create formatted line
143
- file_line = f"📄 {node['name']:<30} {path_display}"
144
- if title and title != node["name"]:
145
- file_line += f" | {title}"
146
- if date_str:
147
- file_line += f" | {date_str}"
148
-
149
- output_lines.append(file_line)
150
-
151
- # Add summary
152
- output_lines.append("")
153
- total_count = len(directories) + len(files)
154
- summary_parts = []
155
- if directories:
156
- summary_parts.append(
157
- f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
82
+ # Call the API endpoint
83
+ response = await call_get(
84
+ client,
85
+ f"{project_url}/directory/list",
86
+ params=params,
158
87
  )
159
- if files:
160
- summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
161
88
 
162
- output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
89
+ nodes = response.json()
90
+
91
+ if not nodes:
92
+ filter_desc = ""
93
+ if file_name_glob:
94
+ filter_desc = f" matching '{file_name_glob}'"
95
+ return f"No files found in directory '{dir_name}'{filter_desc}"
163
96
 
164
- return "\n".join(output_lines)
97
+ # Format the results
98
+ output_lines = []
99
+ if file_name_glob:
100
+ output_lines.append(
101
+ f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):"
102
+ )
103
+ else:
104
+ output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
105
+ output_lines.append("")
106
+
107
+ # Group by type and sort
108
+ directories = [n for n in nodes if n["type"] == "directory"]
109
+ files = [n for n in nodes if n["type"] == "file"]
110
+
111
+ # Sort by name
112
+ directories.sort(key=lambda x: x["name"])
113
+ files.sort(key=lambda x: x["name"])
114
+
115
+ # Display directories first
116
+ for node in directories:
117
+ path_display = node["directory_path"]
118
+ output_lines.append(f"📁 {node['name']:<30} {path_display}")
119
+
120
+ # Add separator if we have both directories and files
121
+ if directories and files:
122
+ output_lines.append("")
123
+
124
+ # Display files with metadata
125
+ for node in files:
126
+ path_display = node["directory_path"]
127
+ title = node.get("title", "")
128
+ updated = node.get("updated_at", "")
129
+
130
+ # Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
131
+ if path_display.startswith("/"):
132
+ path_display = path_display[1:]
133
+
134
+ # Format date if available
135
+ date_str = ""
136
+ if updated:
137
+ try:
138
+ from datetime import datetime
139
+
140
+ dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
141
+ date_str = dt.strftime("%Y-%m-%d")
142
+ except Exception: # pragma: no cover
143
+ date_str = updated[:10] if len(updated) >= 10 else ""
144
+
145
+ # Create formatted line
146
+ file_line = f"📄 {node['name']:<30} {path_display}"
147
+ if title and title != node["name"]:
148
+ file_line += f" | {title}"
149
+ if date_str:
150
+ file_line += f" | {date_str}"
151
+
152
+ output_lines.append(file_line)
153
+
154
+ # Add summary
155
+ output_lines.append("")
156
+ total_count = len(directories) + len(files)
157
+ summary_parts = []
158
+ if directories:
159
+ summary_parts.append(
160
+ f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
161
+ )
162
+ if files:
163
+ summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
164
+
165
+ output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
166
+
167
+ return "\n".join(output_lines)
@@ -6,7 +6,7 @@ from typing import Optional
6
6
  from loguru import logger
7
7
  from fastmcp import Context
8
8
 
9
- from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.async_client import get_client
10
10
  from basic_memory.mcp.server import mcp
11
11
  from basic_memory.mcp.tools.utils import call_post, call_get
12
12
  from basic_memory.mcp.project_context import get_active_project
@@ -16,11 +16,12 @@ from basic_memory.utils import validate_project_path
16
16
 
17
17
 
18
18
  async def _detect_cross_project_move_attempt(
19
- identifier: str, destination_path: str, current_project: str
19
+ client, identifier: str, destination_path: str, current_project: str
20
20
  ) -> Optional[str]:
21
21
  """Detect potential cross-project move attempts and return guidance.
22
22
 
23
23
  Args:
24
+ client: The AsyncClient instance
24
25
  identifier: The note identifier being moved
25
26
  destination_path: The destination path
26
27
  current_project: The current active project
@@ -394,20 +395,21 @@ async def move_note(
394
395
  - Re-indexes the entity for search
395
396
  - Maintains all observations and relations
396
397
  """
397
- logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
398
-
399
- active_project = await get_active_project(client, project, context)
400
- project_url = active_project.project_url
401
-
402
- # Validate destination path to prevent path traversal attacks
403
- project_path = active_project.home
404
- if not validate_project_path(destination_path, project_path):
405
- logger.warning(
406
- "Attempted path traversal attack blocked",
407
- destination_path=destination_path,
408
- project=active_project.name,
409
- )
410
- return f"""# Move Failed - Security Validation Error
398
+ async with get_client() as client:
399
+ logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
400
+
401
+ active_project = await get_active_project(client, project, context)
402
+ project_url = active_project.project_url
403
+
404
+ # Validate destination path to prevent path traversal attacks
405
+ project_path = active_project.home
406
+ if not validate_project_path(destination_path, project_path):
407
+ logger.warning(
408
+ "Attempted path traversal attack blocked",
409
+ destination_path=destination_path,
410
+ project=active_project.name,
411
+ )
412
+ return f"""# Move Failed - Security Validation Error
411
413
 
412
414
  The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
413
415
 
@@ -421,123 +423,123 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
421
423
  move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
422
424
  ```"""
423
425
 
424
- # Check for potential cross-project move attempts
425
- cross_project_error = await _detect_cross_project_move_attempt(
426
- identifier, destination_path, active_project.name
427
- )
428
- if cross_project_error:
429
- logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
430
- return cross_project_error
431
-
432
- # Get the source entity information for extension validation
433
- source_ext = "md" # Default to .md if we can't determine source extension
434
- try:
435
- # Fetch source entity information to get the current file extension
436
- url = f"{project_url}/knowledge/entities/{identifier}"
437
- response = await call_get(client, url)
438
- source_entity = EntityResponse.model_validate(response.json())
439
- if "." in source_entity.file_path:
440
- source_ext = source_entity.file_path.split(".")[-1]
441
- except Exception as e:
442
- # If we can't fetch the source entity, default to .md extension
443
- logger.debug(f"Could not fetch source entity for extension check: {e}")
444
-
445
- # Validate that destination path includes a file extension
446
- if "." not in destination_path or not destination_path.split(".")[-1]:
447
- logger.warning(f"Move failed - no file extension provided: {destination_path}")
448
- return dedent(f"""
449
- # Move Failed - File Extension Required
450
-
451
- The destination path '{destination_path}' must include a file extension (e.g., '.md').
452
-
453
- ## Valid examples:
454
- - `notes/my-note.md`
455
- - `projects/meeting-2025.txt`
456
- - `archive/old-program.sh`
457
-
458
- ## Try again with extension:
459
- ```
460
- move_note("{identifier}", "{destination_path}.{source_ext}")
461
- ```
462
-
463
- All examples in Basic Memory expect file extensions to be explicitly provided.
464
- """).strip()
465
-
466
- # Get the source entity to check its file extension
467
- try:
468
- # Fetch source entity information
469
- url = f"{project_url}/knowledge/entities/{identifier}"
470
- response = await call_get(client, url)
471
- source_entity = EntityResponse.model_validate(response.json())
472
-
473
- # Extract file extensions
474
- source_ext = (
475
- source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
426
+ # Check for potential cross-project move attempts
427
+ cross_project_error = await _detect_cross_project_move_attempt(
428
+ client, identifier, destination_path, active_project.name
476
429
  )
477
- dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
478
-
479
- # Check if extensions match
480
- if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
481
- logger.warning(
482
- f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
483
- )
430
+ if cross_project_error:
431
+ logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
432
+ return cross_project_error
433
+
434
+ # Get the source entity information for extension validation
435
+ source_ext = "md" # Default to .md if we can't determine source extension
436
+ try:
437
+ # Fetch source entity information to get the current file extension
438
+ url = f"{project_url}/knowledge/entities/{identifier}"
439
+ response = await call_get(client, url)
440
+ source_entity = EntityResponse.model_validate(response.json())
441
+ if "." in source_entity.file_path:
442
+ source_ext = source_entity.file_path.split(".")[-1]
443
+ except Exception as e:
444
+ # If we can't fetch the source entity, default to .md extension
445
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
446
+
447
+ # Validate that destination path includes a file extension
448
+ if "." not in destination_path or not destination_path.split(".")[-1]:
449
+ logger.warning(f"Move failed - no file extension provided: {destination_path}")
484
450
  return dedent(f"""
485
- # Move Failed - File Extension Mismatch
486
-
487
- The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
451
+ # Move Failed - File Extension Required
488
452
 
489
- To preserve file type consistency, the destination must have the same extension as the source.
453
+ The destination path '{destination_path}' must include a file extension (e.g., '.md').
490
454
 
491
- ## Source file:
492
- - Path: `{source_entity.file_path}`
493
- - Extension: `.{source_ext}`
455
+ ## Valid examples:
456
+ - `notes/my-note.md`
457
+ - `projects/meeting-2025.txt`
458
+ - `archive/old-program.sh`
494
459
 
495
- ## Try again with matching extension:
460
+ ## Try again with extension:
496
461
  ```
497
- move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
462
+ move_note("{identifier}", "{destination_path}.{source_ext}")
498
463
  ```
464
+
465
+ All examples in Basic Memory expect file extensions to be explicitly provided.
499
466
  """).strip()
500
- except Exception as e:
501
- # If we can't fetch the source entity, log it but continue
502
- # This might happen if the identifier is not yet resolved
503
- logger.debug(f"Could not fetch source entity for extension check: {e}")
504
467
 
505
- try:
506
- # Prepare move request
507
- move_data = {
508
- "identifier": identifier,
509
- "destination_path": destination_path,
510
- "project": active_project.name,
511
- }
512
-
513
- # Call the move API endpoint
514
- url = f"{project_url}/knowledge/move"
515
- response = await call_post(client, url, json=move_data)
516
- result = EntityResponse.model_validate(response.json())
517
-
518
- # Build success message
519
- result_lines = [
520
- "✅ Note moved successfully",
521
- "",
522
- f"📁 **{identifier}** → **{result.file_path}**",
523
- f"🔗 Permalink: {result.permalink}",
524
- "📊 Database and search index updated",
525
- "",
526
- f"<!-- Project: {active_project.name} -->",
527
- ]
528
-
529
- # Log the operation
530
- logger.info(
531
- "Move note completed",
532
- identifier=identifier,
533
- destination_path=destination_path,
534
- project=active_project.name,
535
- status_code=response.status_code,
536
- )
468
+ # Get the source entity to check its file extension
469
+ try:
470
+ # Fetch source entity information
471
+ url = f"{project_url}/knowledge/entities/{identifier}"
472
+ response = await call_get(client, url)
473
+ source_entity = EntityResponse.model_validate(response.json())
537
474
 
538
- return "\n".join(result_lines)
475
+ # Extract file extensions
476
+ source_ext = (
477
+ source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
478
+ )
479
+ dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
539
480
 
540
- except Exception as e:
541
- logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
542
- # Return formatted error message for better user experience
543
- return _format_move_error_response(str(e), identifier, destination_path)
481
+ # Check if extensions match
482
+ if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
483
+ logger.warning(
484
+ f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
485
+ )
486
+ return dedent(f"""
487
+ # Move Failed - File Extension Mismatch
488
+
489
+ The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
490
+
491
+ To preserve file type consistency, the destination must have the same extension as the source.
492
+
493
+ ## Source file:
494
+ - Path: `{source_entity.file_path}`
495
+ - Extension: `.{source_ext}`
496
+
497
+ ## Try again with matching extension:
498
+ ```
499
+ move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
500
+ ```
501
+ """).strip()
502
+ except Exception as e:
503
+ # If we can't fetch the source entity, log it but continue
504
+ # This might happen if the identifier is not yet resolved
505
+ logger.debug(f"Could not fetch source entity for extension check: {e}")
506
+
507
+ try:
508
+ # Prepare move request
509
+ move_data = {
510
+ "identifier": identifier,
511
+ "destination_path": destination_path,
512
+ "project": active_project.name,
513
+ }
514
+
515
+ # Call the move API endpoint
516
+ url = f"{project_url}/knowledge/move"
517
+ response = await call_post(client, url, json=move_data)
518
+ result = EntityResponse.model_validate(response.json())
519
+
520
+ # Build success message
521
+ result_lines = [
522
+ "✅ Note moved successfully",
523
+ "",
524
+ f"📁 **{identifier}** → **{result.file_path}**",
525
+ f"🔗 Permalink: {result.permalink}",
526
+ "📊 Database and search index updated",
527
+ "",
528
+ f"<!-- Project: {active_project.name} -->",
529
+ ]
530
+
531
+ # Log the operation
532
+ logger.info(
533
+ "Move note completed",
534
+ identifier=identifier,
535
+ destination_path=destination_path,
536
+ project=active_project.name,
537
+ status_code=response.status_code,
538
+ )
539
+
540
+ return "\n".join(result_lines)
541
+
542
+ except Exception as e:
543
+ logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
544
+ # Return formatted error message for better user experience
545
+ return _format_move_error_response(str(e), identifier, destination_path)