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