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
@@ -1,17 +1,162 @@
1
- from basic_memory.mcp.tools.utils import call_delete
1
+ from textwrap import dedent
2
+ from typing import Optional
2
3
 
4
+ from loguru import logger
3
5
 
6
+ from basic_memory.mcp.tools.utils import call_delete
4
7
  from basic_memory.mcp.server import mcp
5
8
  from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.project_session import get_active_project
6
10
  from basic_memory.schemas import DeleteEntitiesResponse
7
11
 
8
12
 
13
+ def _format_delete_error_response(error_message: str, identifier: str) -> str:
14
+ """Format helpful error responses for delete failures that guide users to successful deletions."""
15
+
16
+ # Note not found errors
17
+ if "entity not found" in error_message.lower() or "not found" in error_message.lower():
18
+ search_term = identifier.split("/")[-1] if "/" in identifier else identifier
19
+ title_format = (
20
+ identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
21
+ )
22
+ permalink_format = identifier.lower().replace(" ", "-")
23
+
24
+ return dedent(f"""
25
+ # Delete Failed - Note Not Found
26
+
27
+ The note '{identifier}' could not be found for deletion.
28
+
29
+ ## This might mean:
30
+ 1. **Already deleted**: The note may have been deleted previously
31
+ 2. **Wrong identifier**: The identifier format might be incorrect
32
+ 3. **Different project**: The note might be in a different project
33
+
34
+ ## How to verify:
35
+ 1. **Search for the note**: Use `search_notes("{search_term}")` to find it
36
+ 2. **Try different formats**:
37
+ - If you used a permalink like "folder/note-title", try just the title: "{title_format}"
38
+ - If you used a title, try the permalink format: "{permalink_format}"
39
+
40
+ 3. **Check if already deleted**: Use `list_directory("/")` to see what notes exist
41
+ 4. **Check current project**: Use `get_current_project()` to verify you're in the right project
42
+
43
+ ## If the note actually exists:
44
+ ```
45
+ # First, find the correct identifier:
46
+ search_notes("{identifier}")
47
+
48
+ # Then delete using the correct identifier:
49
+ delete_note("correct-identifier-from-search")
50
+ ```
51
+
52
+ ## If you want to delete multiple similar notes:
53
+ Use search to find all related notes and delete them one by one.
54
+ """).strip()
55
+
56
+ # Permission/access errors
57
+ if (
58
+ "permission" in error_message.lower()
59
+ or "access" in error_message.lower()
60
+ or "forbidden" in error_message.lower()
61
+ ):
62
+ return f"""# Delete Failed - Permission Error
63
+
64
+ You don't have permission to delete '{identifier}': {error_message}
65
+
66
+ ## How to resolve:
67
+ 1. **Check permissions**: Verify you have delete/write access to this project
68
+ 2. **File locks**: The note might be open in another application
69
+ 3. **Project access**: Ensure you're in the correct project with proper permissions
70
+
71
+ ## Alternative actions:
72
+ - Check current project: `get_current_project()`
73
+ - Switch to correct project: `switch_project("project-name")`
74
+ - Verify note exists first: `read_note("{identifier}")`
75
+
76
+ ## If you have read-only access:
77
+ Send a message to support@basicmachines.co to request deletion, or ask someone with write access to delete the note."""
78
+
79
+ # Server/filesystem errors
80
+ if (
81
+ "server error" in error_message.lower()
82
+ or "filesystem" in error_message.lower()
83
+ or "disk" in error_message.lower()
84
+ ):
85
+ return f"""# Delete Failed - System Error
86
+
87
+ A system error occurred while deleting '{identifier}': {error_message}
88
+
89
+ ## Immediate steps:
90
+ 1. **Try again**: The error might be temporary
91
+ 2. **Check file status**: Verify the file isn't locked or in use
92
+ 3. **Check disk space**: Ensure the system has adequate storage
93
+
94
+ ## Troubleshooting:
95
+ - Verify note exists: `read_note("{identifier}")`
96
+ - Check project status: `get_current_project()`
97
+ - Try again in a few moments
98
+
99
+ ## If problem persists:
100
+ Send a message to support@basicmachines.co - there may be a filesystem or database issue."""
101
+
102
+ # Database/sync errors
103
+ if "database" in error_message.lower() or "sync" in error_message.lower():
104
+ return f"""# Delete Failed - Database Error
105
+
106
+ A database error occurred while deleting '{identifier}': {error_message}
107
+
108
+ ## This usually means:
109
+ 1. **Sync conflict**: The file system and database are out of sync
110
+ 2. **Database lock**: Another operation is accessing the database
111
+ 3. **Corrupted entry**: The database entry might be corrupted
112
+
113
+ ## Steps to resolve:
114
+ 1. **Try again**: Wait a moment and retry the deletion
115
+ 2. **Check note status**: `read_note("{identifier}")` to see current state
116
+ 3. **Manual verification**: Use `list_directory()` to see if file still exists
117
+
118
+ ## If the note appears gone but database shows it exists:
119
+ Send a message to support@basicmachines.co - a manual database cleanup may be needed."""
120
+
121
+ # Generic fallback
122
+ return f"""# Delete Failed
123
+
124
+ Error deleting note '{identifier}': {error_message}
125
+
126
+ ## General troubleshooting:
127
+ 1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
128
+ 2. **Check permissions**: Ensure you can edit/delete files in this project
129
+ 3. **Try again**: The error might be temporary
130
+ 4. **Check project**: Make sure you're in the correct project
131
+
132
+ ## Step-by-step approach:
133
+ ```
134
+ # 1. Confirm note exists and get correct identifier
135
+ search_notes("{identifier}")
136
+
137
+ # 2. Read the note to verify access
138
+ read_note("correct-identifier-from-search")
139
+
140
+ # 3. Try deletion with correct identifier
141
+ delete_note("correct-identifier-from-search")
142
+ ```
143
+
144
+ ## Alternative approaches:
145
+ - Check what notes exist: `list_directory("/")`
146
+ - Verify current project: `get_current_project()`
147
+ - Switch projects if needed: `switch_project("correct-project")`
148
+
149
+ ## Need help?
150
+ If the note should be deleted but the operation keeps failing, send a message to support@basicmachines.co."""
151
+
152
+
9
153
  @mcp.tool(description="Delete a note by title or permalink")
10
- async def delete_note(identifier: str) -> bool:
154
+ async def delete_note(identifier: str, project: Optional[str] = None) -> bool | str:
11
155
  """Delete a note from the knowledge base.
12
156
 
13
157
  Args:
14
158
  identifier: Note title or permalink
159
+ project: Optional project name to delete from. If not provided, uses current active project.
15
160
 
16
161
  Returns:
17
162
  True if note was deleted, False otherwise
@@ -22,7 +167,25 @@ async def delete_note(identifier: str) -> bool:
22
167
 
23
168
  # Delete by permalink
24
169
  delete_note("notes/project-planning")
170
+
171
+ # Delete from specific project
172
+ delete_note("notes/project-planning", project="work-project")
25
173
  """
26
- response = await call_delete(client, f"/knowledge/entities/{identifier}")
27
- result = DeleteEntitiesResponse.model_validate(response.json())
28
- return result.deleted
174
+ active_project = get_active_project(project)
175
+ project_url = active_project.project_url
176
+
177
+ try:
178
+ response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
179
+ result = DeleteEntitiesResponse.model_validate(response.json())
180
+
181
+ if result.deleted:
182
+ logger.info(f"Successfully deleted note: {identifier}")
183
+ return True
184
+ else:
185
+ logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
186
+ return False
187
+
188
+ except Exception as e: # pragma: no cover
189
+ logger.error(f"Delete failed for '{identifier}': {e}")
190
+ # Return formatted error message for better user experience
191
+ return _format_delete_error_response(str(e), identifier)
@@ -0,0 +1,303 @@
1
+ """Edit note tool for Basic Memory MCP server."""
2
+
3
+ from typing import Optional
4
+
5
+ from loguru import logger
6
+
7
+ from basic_memory.mcp.async_client import client
8
+ from basic_memory.mcp.project_session import get_active_project
9
+ from basic_memory.mcp.server import mcp
10
+ from basic_memory.mcp.tools.utils import call_patch
11
+ from basic_memory.schemas import EntityResponse
12
+
13
+
14
+ def _format_error_response(
15
+ error_message: str,
16
+ operation: str,
17
+ identifier: str,
18
+ find_text: Optional[str] = None,
19
+ expected_replacements: int = 1,
20
+ ) -> str:
21
+ """Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
22
+
23
+ # Entity not found errors
24
+ if "Entity not found" in error_message or "entity not found" in error_message.lower():
25
+ return f"""# Edit Failed - Note Not Found
26
+
27
+ The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
28
+
29
+ ## Suggestions to try:
30
+ 1. **Search for the note first**: Use `search_notes("{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
31
+ 2. **Try different exact identifier formats**:
32
+ - If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
33
+ - If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
34
+ - Use `read_note()` first to verify the note exists and get the exact identifier
35
+
36
+ ## Alternative approach:
37
+ Use `write_note()` to create the note first, then edit it."""
38
+
39
+ # Find/replace specific errors
40
+ if operation == "find_replace":
41
+ if "Text to replace not found" in error_message:
42
+ return f"""# Edit Failed - Text Not Found
43
+
44
+ The text '{find_text}' was not found in the note '{identifier}'.
45
+
46
+ ## Suggestions to try:
47
+ 1. **Read the note first**: Use `read_note("{identifier}")` to see the current content
48
+ 2. **Check for exact matches**: The search is case-sensitive and must match exactly
49
+ 3. **Try a broader search**: Search for just part of the text you want to replace
50
+ 4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
51
+
52
+ ## Alternative approaches:
53
+ - Use `append` or `prepend` to add new content instead
54
+ - Use `replace_section` if you're trying to update a specific section"""
55
+
56
+ if "Expected" in error_message and "occurrences" in error_message:
57
+ # Extract the actual count from error message if possible
58
+ import re
59
+
60
+ match = re.search(r"found (\d+)", error_message)
61
+ actual_count = match.group(1) if match else "a different number of"
62
+
63
+ return f"""# Edit Failed - Wrong Replacement Count
64
+
65
+ Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
66
+
67
+ ## How to fix:
68
+ 1. **Read the note first**: Use `read_note("{identifier}")` to see how many times '{find_text}' appears
69
+ 2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
70
+ 3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
71
+
72
+ ## Example:
73
+ ```
74
+ edit_note("{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
75
+ ```"""
76
+
77
+ # Section replacement errors
78
+ if operation == "replace_section" and "Multiple sections" in error_message:
79
+ return f"""# Edit Failed - Duplicate Section Headers
80
+
81
+ Multiple sections found with the same header in note '{identifier}'.
82
+
83
+ ## How to fix:
84
+ 1. **Read the note first**: Use `read_note("{identifier}")` to see the document structure
85
+ 2. **Make headers unique**: Add more specific text to distinguish sections
86
+ 3. **Use append instead**: Add content at the end rather than replacing a specific section
87
+
88
+ ## Alternative approach:
89
+ Use `find_replace` to update specific text within the duplicate sections."""
90
+
91
+ # Generic server/request errors
92
+ if (
93
+ "Invalid request" in error_message or "malformed" in error_message.lower()
94
+ ): # pragma: no cover
95
+ return f"""# Edit Failed - Request Error
96
+
97
+ There was a problem with the edit request to note '{identifier}': {error_message}.
98
+
99
+ ## Common causes and fixes:
100
+ 1. **Note doesn't exist**: Use `search_notes()` or `read_note()` to verify the note exists
101
+ 2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
102
+ 3. **Empty or invalid content**: Check that your content is properly formatted
103
+ 4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
104
+
105
+ ## Troubleshooting steps:
106
+ 1. Verify the note exists: `read_note("{identifier}")`
107
+ 2. If not found, search for it: `search_notes("{identifier.split("/")[-1]}")`
108
+ 3. Try again with the correct identifier from the search results"""
109
+
110
+ # Fallback for other errors
111
+ return f"""# Edit Failed
112
+
113
+ Error editing note '{identifier}': {error_message}
114
+
115
+ ## General troubleshooting:
116
+ 1. **Verify the note exists**: Use `read_note("{identifier}")` to check
117
+ 2. **Check your parameters**: Ensure all required parameters are provided correctly
118
+ 3. **Read the note content first**: Use `read_note()` to understand the current structure
119
+ 4. **Try a simpler operation**: Start with `append` if other operations fail
120
+
121
+ ## Need help?
122
+ - Use `search_notes()` to find notes
123
+ - Use `read_note()` to examine content before editing
124
+ - Check that identifiers, section headers, and find_text match exactly"""
125
+
126
+
127
+ @mcp.tool(
128
+ description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
129
+ )
130
+ async def edit_note(
131
+ identifier: str,
132
+ operation: str,
133
+ content: str,
134
+ section: Optional[str] = None,
135
+ find_text: Optional[str] = None,
136
+ expected_replacements: int = 1,
137
+ project: Optional[str] = None,
138
+ ) -> str:
139
+ """Edit an existing markdown note in the knowledge base.
140
+
141
+ This tool allows you to make targeted changes to existing notes without rewriting the entire content.
142
+ It supports various operations for different editing scenarios.
143
+
144
+ Args:
145
+ identifier: The exact title, permalink, or memory:// URL of the note to edit.
146
+ Must be an exact match - fuzzy matching is not supported for edit operations.
147
+ Use search_notes() or read_note() first to find the correct identifier if uncertain.
148
+ operation: The editing operation to perform:
149
+ - "append": Add content to the end of the note
150
+ - "prepend": Add content to the beginning of the note
151
+ - "find_replace": Replace occurrences of find_text with content
152
+ - "replace_section": Replace content under a specific markdown header
153
+ content: The content to add or use for replacement
154
+ section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
155
+ find_text: For find_replace operation - the text to find and replace
156
+ expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
157
+ project: Optional project name to delete from. If not provided, uses current active project.
158
+
159
+ Returns:
160
+ A markdown formatted summary of the edit operation and resulting semantic content
161
+
162
+ Examples:
163
+ # Add new content to end of note
164
+ edit_note("project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
165
+
166
+ # Add timestamp at beginning (frontmatter-aware)
167
+ edit_note("meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
168
+
169
+ # Update version number (single occurrence)
170
+ edit_note("config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
171
+
172
+ # Update version in multiple places with validation
173
+ edit_note("api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
174
+
175
+ # Replace text that appears multiple times - validate count first
176
+ edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
177
+
178
+ # Replace implementation section
179
+ edit_note("api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
180
+
181
+ # Replace subsection with more specific header
182
+ edit_note("docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
183
+
184
+ # Using different identifier formats (must be exact matches)
185
+ edit_note("Meeting Notes", "append", "\\n- Follow up on action items") # exact title
186
+ edit_note("docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
187
+ edit_note("docs/Meeting Notes", "append", "\\n- Next steps") # exact folder/title
188
+
189
+ # If uncertain about identifier, search first:
190
+ # search_notes("meeting") # Find available notes
191
+ # edit_note("docs/meeting-notes-2025", "append", "content") # Use exact result
192
+
193
+ # Add new section to document
194
+ edit_note("project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
195
+
196
+ # Update status across document (expecting exactly 2 occurrences)
197
+ edit_note("status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
198
+
199
+ # Replace text in a file, specifying project name
200
+ edit_note("docs/guide", "find_replace", "new-api", find_text="old-api", project="my-project"))
201
+
202
+ """
203
+ active_project = get_active_project(project)
204
+ project_url = active_project.project_url
205
+
206
+ logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
207
+
208
+ # Validate operation
209
+ valid_operations = ["append", "prepend", "find_replace", "replace_section"]
210
+ if operation not in valid_operations:
211
+ raise ValueError(
212
+ f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
213
+ )
214
+
215
+ # Validate required parameters for specific operations
216
+ if operation == "find_replace" and not find_text:
217
+ raise ValueError("find_text parameter is required for find_replace operation")
218
+ if operation == "replace_section" and not section:
219
+ raise ValueError("section parameter is required for replace_section operation")
220
+
221
+ # Use the PATCH endpoint to edit the entity
222
+ try:
223
+ # Prepare the edit request data
224
+ edit_data = {
225
+ "operation": operation,
226
+ "content": content,
227
+ }
228
+
229
+ # Add optional parameters
230
+ if section:
231
+ edit_data["section"] = section
232
+ if find_text:
233
+ edit_data["find_text"] = find_text
234
+ if expected_replacements != 1: # Only send if different from default
235
+ edit_data["expected_replacements"] = str(expected_replacements)
236
+
237
+ # Call the PATCH endpoint
238
+ url = f"{project_url}/knowledge/entities/{identifier}"
239
+ response = await call_patch(client, url, json=edit_data)
240
+ result = EntityResponse.model_validate(response.json())
241
+
242
+ # Format summary
243
+ summary = [
244
+ f"# Edited note ({operation})",
245
+ f"project: {active_project.name}",
246
+ f"file_path: {result.file_path}",
247
+ f"permalink: {result.permalink}",
248
+ f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
249
+ ]
250
+
251
+ # Add operation-specific details
252
+ if operation == "append":
253
+ lines_added = len(content.split("\n"))
254
+ summary.append(f"operation: Added {lines_added} lines to end of note")
255
+ elif operation == "prepend":
256
+ lines_added = len(content.split("\n"))
257
+ summary.append(f"operation: Added {lines_added} lines to beginning of note")
258
+ elif operation == "find_replace":
259
+ # For find_replace, we can't easily count replacements from here
260
+ # since we don't have the original content, but the server handled it
261
+ summary.append("operation: Find and replace operation completed")
262
+ elif operation == "replace_section":
263
+ summary.append(f"operation: Replaced content under section '{section}'")
264
+
265
+ # Count observations by category (reuse logic from write_note)
266
+ categories = {}
267
+ if result.observations:
268
+ for obs in result.observations:
269
+ categories[obs.category] = categories.get(obs.category, 0) + 1
270
+
271
+ summary.append("\\n## Observations")
272
+ for category, count in sorted(categories.items()):
273
+ summary.append(f"- {category}: {count}")
274
+
275
+ # Count resolved/unresolved relations
276
+ unresolved = 0
277
+ resolved = 0
278
+ if result.relations:
279
+ unresolved = sum(1 for r in result.relations if not r.to_id)
280
+ resolved = len(result.relations) - unresolved
281
+
282
+ summary.append("\\n## Relations")
283
+ summary.append(f"- Resolved: {resolved}")
284
+ if unresolved:
285
+ summary.append(f"- Unresolved: {unresolved}")
286
+
287
+ logger.info(
288
+ "MCP tool response",
289
+ tool="edit_note",
290
+ operation=operation,
291
+ permalink=result.permalink,
292
+ observations_count=len(result.observations),
293
+ relations_count=len(result.relations),
294
+ status_code=response.status_code,
295
+ )
296
+
297
+ return "\n".join(summary)
298
+
299
+ except Exception as e:
300
+ logger.error(f"Error editing note: {e}")
301
+ return _format_error_response(
302
+ str(e), operation, identifier, find_text, expected_replacements
303
+ )
@@ -0,0 +1,154 @@
1
+ """List directory tool for Basic Memory MCP server."""
2
+
3
+ from typing import Optional
4
+
5
+ from loguru import logger
6
+
7
+ from basic_memory.mcp.async_client import client
8
+ from basic_memory.mcp.project_session import get_active_project
9
+ from basic_memory.mcp.server import mcp
10
+ from basic_memory.mcp.tools.utils import call_get
11
+
12
+
13
+ @mcp.tool(
14
+ description="List directory contents with filtering and depth control.",
15
+ )
16
+ async def list_directory(
17
+ dir_name: str = "/",
18
+ depth: int = 1,
19
+ file_name_glob: Optional[str] = None,
20
+ project: Optional[str] = None,
21
+ ) -> str:
22
+ """List directory contents from the knowledge base with optional filtering.
23
+
24
+ This tool provides 'ls' functionality for browsing the knowledge base directory structure.
25
+ It can list immediate children or recursively explore subdirectories with depth control,
26
+ and supports glob pattern filtering for finding specific files.
27
+
28
+ Args:
29
+ dir_name: Directory path to list (default: root "/")
30
+ Examples: "/", "/projects", "/research/ml"
31
+ depth: Recursion depth (1-10, default: 1 for immediate children only)
32
+ Higher values show subdirectory contents recursively
33
+ file_name_glob: Optional glob pattern for filtering file names
34
+ Examples: "*.md", "*meeting*", "project_*"
35
+ project: Optional project name to delete from. If not provided, uses current active project.
36
+ Returns:
37
+ Formatted listing of directory contents with file metadata
38
+
39
+ Examples:
40
+ # List root directory contents
41
+ list_directory()
42
+
43
+ # List specific folder
44
+ list_directory(dir_name="/projects")
45
+
46
+ # Find all Python files
47
+ list_directory(file_name_glob="*.py")
48
+
49
+ # Deep exploration of research folder
50
+ list_directory(dir_name="/research", depth=3)
51
+
52
+ # Find meeting notes in projects folder
53
+ list_directory(dir_name="/projects", file_name_glob="*meeting*")
54
+
55
+ # Find meeting notes in a specific project
56
+ list_directory(dir_name="/projects", file_name_glob="*meeting*", project="work-project")
57
+ """
58
+ active_project = get_active_project(project)
59
+ project_url = active_project.project_url
60
+
61
+ # Prepare query parameters
62
+ params = {
63
+ "dir_name": dir_name,
64
+ "depth": str(depth),
65
+ }
66
+ if file_name_glob:
67
+ params["file_name_glob"] = file_name_glob
68
+
69
+ logger.debug(f"Listing directory '{dir_name}' with depth={depth}, glob='{file_name_glob}'")
70
+
71
+ # Call the API endpoint
72
+ response = await call_get(
73
+ client,
74
+ f"{project_url}/directory/list",
75
+ params=params,
76
+ )
77
+
78
+ nodes = response.json()
79
+
80
+ if not nodes:
81
+ filter_desc = ""
82
+ if file_name_glob:
83
+ filter_desc = f" matching '{file_name_glob}'"
84
+ return f"No files found in directory '{dir_name}'{filter_desc}"
85
+
86
+ # Format the results
87
+ output_lines = []
88
+ if file_name_glob:
89
+ output_lines.append(f"Files in '{dir_name}' matching '{file_name_glob}' (depth {depth}):")
90
+ else:
91
+ output_lines.append(f"Contents of '{dir_name}' (depth {depth}):")
92
+ output_lines.append("")
93
+
94
+ # Group by type and sort
95
+ directories = [n for n in nodes if n["type"] == "directory"]
96
+ files = [n for n in nodes if n["type"] == "file"]
97
+
98
+ # Sort by name
99
+ directories.sort(key=lambda x: x["name"])
100
+ files.sort(key=lambda x: x["name"])
101
+
102
+ # Display directories first
103
+ for node in directories:
104
+ path_display = node["directory_path"]
105
+ output_lines.append(f"📁 {node['name']:<30} {path_display}")
106
+
107
+ # Add separator if we have both directories and files
108
+ if directories and files:
109
+ output_lines.append("")
110
+
111
+ # Display files with metadata
112
+ for node in files:
113
+ path_display = node["directory_path"]
114
+ title = node.get("title", "")
115
+ updated = node.get("updated_at", "")
116
+
117
+ # Remove leading slash if present, requesting the file via read_note does not use the beginning slash'
118
+ if path_display.startswith("/"):
119
+ path_display = path_display[1:]
120
+
121
+ # Format date if available
122
+ date_str = ""
123
+ if updated:
124
+ try:
125
+ from datetime import datetime
126
+
127
+ dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
128
+ date_str = dt.strftime("%Y-%m-%d")
129
+ except Exception: # pragma: no cover
130
+ date_str = updated[:10] if len(updated) >= 10 else ""
131
+
132
+ # Create formatted line
133
+ file_line = f"📄 {node['name']:<30} {path_display}"
134
+ if title and title != node["name"]:
135
+ file_line += f" | {title}"
136
+ if date_str:
137
+ file_line += f" | {date_str}"
138
+
139
+ output_lines.append(file_line)
140
+
141
+ # Add summary
142
+ output_lines.append("")
143
+ total_count = len(directories) + len(files)
144
+ summary_parts = []
145
+ if directories:
146
+ summary_parts.append(
147
+ f"{len(directories)} director{'y' if len(directories) == 1 else 'ies'}"
148
+ )
149
+ if files:
150
+ summary_parts.append(f"{len(files)} file{'s' if len(files) != 1 else ''}")
151
+
152
+ output_lines.append(f"Total: {total_count} items ({', '.join(summary_parts)})")
153
+
154
+ return "\n".join(output_lines)