basic-memory 0.7.0__py3-none-any.whl → 0.16.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 (150) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +64 -18
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -23
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +411 -62
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +383 -51
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,320 @@
1
+ """Edit note tool for Basic Memory MCP server."""
2
+
3
+ from typing import Optional
4
+
5
+ from loguru import logger
6
+ from fastmcp import Context
7
+
8
+ from basic_memory.mcp.async_client import get_client
9
+ from basic_memory.mcp.project_context import get_active_project, add_project_metadata
10
+ from basic_memory.mcp.server import mcp
11
+ from basic_memory.mcp.tools.utils import call_patch
12
+ from basic_memory.schemas import EntityResponse
13
+
14
+
15
+ def _format_error_response(
16
+ error_message: str,
17
+ operation: str,
18
+ identifier: str,
19
+ find_text: Optional[str] = None,
20
+ expected_replacements: int = 1,
21
+ project: Optional[str] = None,
22
+ ) -> str:
23
+ """Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
24
+
25
+ # Entity not found errors
26
+ if "Entity not found" in error_message or "entity not found" in error_message.lower():
27
+ return f"""# Edit Failed - Note Not Found
28
+
29
+ The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
30
+
31
+ ## Suggestions to try:
32
+ 1. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
33
+ 2. **Try different exact identifier formats**:
34
+ - If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
35
+ - If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
36
+ - Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
37
+
38
+ ## Alternative approach:
39
+ Use `write_note("{project or "project-name"}", "title", "content", "folder")` to create the note first, then edit it."""
40
+
41
+ # Find/replace specific errors
42
+ if operation == "find_replace":
43
+ if "Text to replace not found" in error_message:
44
+ return f"""# Edit Failed - Text Not Found
45
+
46
+ The text '{find_text}' was not found in the note '{identifier}'.
47
+
48
+ ## Suggestions to try:
49
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the current content
50
+ 2. **Check for exact matches**: The search is case-sensitive and must match exactly
51
+ 3. **Try a broader search**: Search for just part of the text you want to replace
52
+ 4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
53
+
54
+ ## Alternative approaches:
55
+ - Use `append` or `prepend` to add new content instead
56
+ - Use `replace_section` if you're trying to update a specific section"""
57
+
58
+ if "Expected" in error_message and "occurrences" in error_message:
59
+ # Extract the actual count from error message if possible
60
+ import re
61
+
62
+ match = re.search(r"found (\d+)", error_message)
63
+ actual_count = match.group(1) if match else "a different number of"
64
+
65
+ return f"""# Edit Failed - Wrong Replacement Count
66
+
67
+ Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
68
+
69
+ ## How to fix:
70
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see how many times '{find_text}' appears
71
+ 2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
72
+ 3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
73
+
74
+ ## Example:
75
+ ```
76
+ edit_note("{project or "project-name"}", "{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
77
+ ```"""
78
+
79
+ # Section replacement errors
80
+ if operation == "replace_section" and "Multiple sections" in error_message:
81
+ return f"""# Edit Failed - Duplicate Section Headers
82
+
83
+ Multiple sections found with the same header in note '{identifier}'.
84
+
85
+ ## How to fix:
86
+ 1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the document structure
87
+ 2. **Make headers unique**: Add more specific text to distinguish sections
88
+ 3. **Use append instead**: Add content at the end rather than replacing a specific section
89
+
90
+ ## Alternative approach:
91
+ Use `find_replace` to update specific text within the duplicate sections."""
92
+
93
+ # Generic server/request errors
94
+ if (
95
+ "Invalid request" in error_message or "malformed" in error_message.lower()
96
+ ): # pragma: no cover
97
+ return f"""# Edit Failed - Request Error
98
+
99
+ There was a problem with the edit request to note '{identifier}': {error_message}.
100
+
101
+ ## Common causes and fixes:
102
+ 1. **Note doesn't exist**: Use `search_notes("{project or "project-name"}", "query")` or `read_note("{project or "project-name"}", "{identifier}")` to verify the note exists
103
+ 2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
104
+ 3. **Empty or invalid content**: Check that your content is properly formatted
105
+ 4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
106
+
107
+ ## Troubleshooting steps:
108
+ 1. Verify the note exists: `read_note("{project or "project-name"}", "{identifier}")`
109
+ 2. If not found, search for it: `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")`
110
+ 3. Try again with the correct identifier from the search results"""
111
+
112
+ # Fallback for other errors
113
+ return f"""# Edit Failed
114
+
115
+ Error editing note '{identifier}': {error_message}
116
+
117
+ ## General troubleshooting:
118
+ 1. **Verify the note exists**: Use `read_note("{project or "project-name"}", "{identifier}")` to check
119
+ 2. **Check your parameters**: Ensure all required parameters are provided correctly
120
+ 3. **Read the note content first**: Use `read_note("{project or "project-name"}", "{identifier}")` to understand the current structure
121
+ 4. **Try a simpler operation**: Start with `append` if other operations fail
122
+
123
+ ## Need help?
124
+ - Use `search_notes("{project or "project-name"}", "query")` to find notes
125
+ - Use `read_note("{project or "project-name"}", "identifier")` to examine content before editing
126
+ - Check that identifiers, section headers, and find_text match exactly"""
127
+
128
+
129
+ @mcp.tool(
130
+ description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
131
+ )
132
+ async def edit_note(
133
+ identifier: str,
134
+ operation: str,
135
+ content: str,
136
+ project: Optional[str] = None,
137
+ section: Optional[str] = None,
138
+ find_text: Optional[str] = None,
139
+ expected_replacements: int = 1,
140
+ context: Context | None = None,
141
+ ) -> str:
142
+ """Edit an existing markdown note in the knowledge base.
143
+
144
+ Makes targeted changes to existing notes without rewriting the entire content.
145
+
146
+ Project Resolution:
147
+ Server resolves projects in this order: Single Project Mode → project parameter → default project.
148
+ If project unknown, use list_memory_projects() or recent_activity() first.
149
+
150
+ Args:
151
+ identifier: The exact title, permalink, or memory:// URL of the note to edit.
152
+ Must be an exact match - fuzzy matching is not supported for edit operations.
153
+ Use search_notes() or read_note() first to find the correct identifier if uncertain.
154
+ operation: The editing operation to perform:
155
+ - "append": Add content to the end of the note
156
+ - "prepend": Add content to the beginning of the note
157
+ - "find_replace": Replace occurrences of find_text with content
158
+ - "replace_section": Replace content under a specific markdown header
159
+ content: The content to add or use for replacement
160
+ project: Project name to edit in. Optional - server will resolve using hierarchy.
161
+ If unknown, use list_memory_projects() to discover available projects.
162
+ section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
163
+ find_text: For find_replace operation - the text to find and replace
164
+ expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
165
+ context: Optional FastMCP context for performance caching.
166
+
167
+ Returns:
168
+ A markdown formatted summary of the edit operation and resulting semantic content,
169
+ including operation details, file path, observations, relations, and project metadata.
170
+
171
+ Examples:
172
+ # Add new content to end of note
173
+ edit_note("my-project", "project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
174
+
175
+ # Add timestamp at beginning (frontmatter-aware)
176
+ edit_note("work-docs", "meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
177
+
178
+ # Update version number (single occurrence)
179
+ edit_note("api-project", "config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
180
+
181
+ # Update version in multiple places with validation
182
+ edit_note("docs-project", "api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
183
+
184
+ # Replace text that appears multiple times - validate count first
185
+ edit_note("team-docs", "docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
186
+
187
+ # Replace implementation section
188
+ edit_note("specs", "api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
189
+
190
+ # Replace subsection with more specific header
191
+ edit_note("docs", "docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
192
+
193
+ # Using different identifier formats (must be exact matches)
194
+ edit_note("work-project", "Meeting Notes", "append", "\\n- Follow up on action items") # exact title
195
+ edit_note("work-project", "docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
196
+
197
+ # If uncertain about identifier, search first:
198
+ # search_notes("work-project", "meeting") # Find available notes
199
+ # edit_note("work-project", "docs/meeting-notes-2025", "append", "content") # Use exact result
200
+
201
+ # Add new section to document
202
+ edit_note("planning", "project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
203
+
204
+ # Update status across document (expecting exactly 2 occurrences)
205
+ edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
206
+
207
+ Raises:
208
+ HTTPError: If project doesn't exist or is inaccessible
209
+ ValueError: If operation is invalid or required parameters are missing
210
+ SecurityError: If identifier attempts path traversal
211
+
212
+ Note:
213
+ Edit operations require exact identifier matches. If unsure, use read_note() or
214
+ search_notes() first to find the correct identifier. The tool provides detailed
215
+ error messages with suggestions if operations fail.
216
+ """
217
+ async with get_client() as client:
218
+ active_project = await get_active_project(client, project, context)
219
+ project_url = active_project.project_url
220
+
221
+ logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
222
+
223
+ # Validate operation
224
+ valid_operations = ["append", "prepend", "find_replace", "replace_section"]
225
+ if operation not in valid_operations:
226
+ raise ValueError(
227
+ f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
228
+ )
229
+
230
+ # Validate required parameters for specific operations
231
+ if operation == "find_replace" and not find_text:
232
+ raise ValueError("find_text parameter is required for find_replace operation")
233
+ if operation == "replace_section" and not section:
234
+ raise ValueError("section parameter is required for replace_section operation")
235
+
236
+ # Use the PATCH endpoint to edit the entity
237
+ try:
238
+ # Prepare the edit request data
239
+ edit_data = {
240
+ "operation": operation,
241
+ "content": content,
242
+ }
243
+
244
+ # Add optional parameters
245
+ if section:
246
+ edit_data["section"] = section
247
+ if find_text:
248
+ edit_data["find_text"] = find_text
249
+ if expected_replacements != 1: # Only send if different from default
250
+ edit_data["expected_replacements"] = str(expected_replacements)
251
+
252
+ # Call the PATCH endpoint
253
+ url = f"{project_url}/knowledge/entities/{identifier}"
254
+ response = await call_patch(client, url, json=edit_data)
255
+ result = EntityResponse.model_validate(response.json())
256
+
257
+ # Format summary
258
+ summary = [
259
+ f"# Edited note ({operation})",
260
+ f"project: {active_project.name}",
261
+ f"file_path: {result.file_path}",
262
+ f"permalink: {result.permalink}",
263
+ f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
264
+ ]
265
+
266
+ # Add operation-specific details
267
+ if operation == "append":
268
+ lines_added = len(content.split("\n"))
269
+ summary.append(f"operation: Added {lines_added} lines to end of note")
270
+ elif operation == "prepend":
271
+ lines_added = len(content.split("\n"))
272
+ summary.append(f"operation: Added {lines_added} lines to beginning of note")
273
+ elif operation == "find_replace":
274
+ # For find_replace, we can't easily count replacements from here
275
+ # since we don't have the original content, but the server handled it
276
+ summary.append("operation: Find and replace operation completed")
277
+ elif operation == "replace_section":
278
+ summary.append(f"operation: Replaced content under section '{section}'")
279
+
280
+ # Count observations by category (reuse logic from write_note)
281
+ categories = {}
282
+ if result.observations:
283
+ for obs in result.observations:
284
+ categories[obs.category] = categories.get(obs.category, 0) + 1
285
+
286
+ summary.append("\\n## Observations")
287
+ for category, count in sorted(categories.items()):
288
+ summary.append(f"- {category}: {count}")
289
+
290
+ # Count resolved/unresolved relations
291
+ unresolved = 0
292
+ resolved = 0
293
+ if result.relations:
294
+ unresolved = sum(1 for r in result.relations if not r.to_id)
295
+ resolved = len(result.relations) - unresolved
296
+
297
+ summary.append("\\n## Relations")
298
+ summary.append(f"- Resolved: {resolved}")
299
+ if unresolved:
300
+ summary.append(f"- Unresolved: {unresolved}")
301
+
302
+ logger.info(
303
+ "MCP tool response",
304
+ tool="edit_note",
305
+ operation=operation,
306
+ project=active_project.name,
307
+ permalink=result.permalink,
308
+ observations_count=len(result.observations),
309
+ relations_count=len(result.relations),
310
+ status_code=response.status_code,
311
+ )
312
+
313
+ result = "\n".join(summary)
314
+ return add_project_metadata(result, active_project.name)
315
+
316
+ except Exception as e:
317
+ logger.error(f"Error editing note: {e}")
318
+ return _format_error_response(
319
+ str(e), operation, identifier, find_text, expected_replacements, active_project.name
320
+ )
@@ -0,0 +1,167 @@
1
+ """List directory tool for Basic Memory MCP server."""
2
+
3
+ from typing import Optional
4
+
5
+ from loguru import logger
6
+ from fastmcp import Context
7
+
8
+ from basic_memory.mcp.async_client import get_client
9
+ from basic_memory.mcp.project_context import get_active_project
10
+ from basic_memory.mcp.server import mcp
11
+ from basic_memory.mcp.tools.utils import call_get
12
+
13
+
14
+ @mcp.tool(
15
+ description="List directory contents with filtering and depth control.",
16
+ )
17
+ async def list_directory(
18
+ dir_name: str = "/",
19
+ depth: int = 1,
20
+ file_name_glob: Optional[str] = None,
21
+ project: Optional[str] = None,
22
+ context: Context | None = None,
23
+ ) -> str:
24
+ """List directory contents from the knowledge base with optional filtering.
25
+
26
+ This tool provides 'ls' functionality for browsing the knowledge base directory structure.
27
+ It can list immediate children or recursively explore subdirectories with depth control,
28
+ and supports glob pattern filtering for finding specific files.
29
+
30
+ Args:
31
+ dir_name: Directory path to list (default: root "/")
32
+ Examples: "/", "/projects", "/research/ml"
33
+ depth: Recursion depth (1-10, default: 1 for immediate children only)
34
+ Higher values show subdirectory contents recursively
35
+ file_name_glob: Optional glob pattern for filtering file names
36
+ Examples: "*.md", "*meeting*", "project_*"
37
+ project: Project name to list directory from. Optional - server will resolve using hierarchy.
38
+ If unknown, use list_memory_projects() to discover available projects.
39
+ context: Optional FastMCP context for performance caching.
40
+
41
+ Returns:
42
+ Formatted listing of directory contents with file metadata
43
+
44
+ Examples:
45
+ # List root directory contents
46
+ list_directory()
47
+
48
+ # List specific folder
49
+ list_directory(dir_name="/projects")
50
+
51
+ # Find all markdown files
52
+ list_directory(file_name_glob="*.md")
53
+
54
+ # Deep exploration of research folder
55
+ list_directory(dir_name="/research", depth=3)
56
+
57
+ # Find meeting notes in projects folder
58
+ list_directory(dir_name="/projects", file_name_glob="*meeting*")
59
+
60
+ # Explicit project specification
61
+ list_directory(project="work-docs", dir_name="/projects")
62
+
63
+ Raises:
64
+ ToolError: If project doesn't exist or directory path is invalid
65
+ """
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
+ }
75
+ if file_name_glob:
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
+ )
81
+
82
+ # Call the API endpoint
83
+ response = await call_get(
84
+ client,
85
+ f"{project_url}/directory/list",
86
+ params=params,
87
+ )
88
+
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}"
96
+
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)