basic-memory 0.17.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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,864 @@
1
+ """Service for managing entities in the database."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional, Sequence, Tuple, Union
5
+
6
+ import frontmatter
7
+ import yaml
8
+ from loguru import logger
9
+ from sqlalchemy.exc import IntegrityError
10
+
11
+
12
+ from basic_memory.config import ProjectConfig, BasicMemoryConfig
13
+ from basic_memory.file_utils import (
14
+ has_frontmatter,
15
+ parse_frontmatter,
16
+ remove_frontmatter,
17
+ dump_frontmatter,
18
+ )
19
+ from basic_memory.markdown import EntityMarkdown
20
+ from basic_memory.markdown.entity_parser import EntityParser
21
+ from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
22
+ from basic_memory.models import Entity as EntityModel
23
+ from basic_memory.models import Observation, Relation
24
+ from basic_memory.models.knowledge import Entity
25
+ from basic_memory.repository import ObservationRepository, RelationRepository
26
+ from basic_memory.repository.entity_repository import EntityRepository
27
+ from basic_memory.schemas import Entity as EntitySchema
28
+ from basic_memory.schemas.base import Permalink
29
+ from basic_memory.services import BaseService, FileService
30
+ from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
31
+ from basic_memory.services.link_resolver import LinkResolver
32
+ from basic_memory.services.search_service import SearchService
33
+ from basic_memory.utils import generate_permalink
34
+
35
+
36
+ class EntityService(BaseService[EntityModel]):
37
+ """Service for managing entities in the database."""
38
+
39
+ def __init__(
40
+ self,
41
+ entity_parser: EntityParser,
42
+ entity_repository: EntityRepository,
43
+ observation_repository: ObservationRepository,
44
+ relation_repository: RelationRepository,
45
+ file_service: FileService,
46
+ link_resolver: LinkResolver,
47
+ search_service: Optional[SearchService] = None,
48
+ app_config: Optional[BasicMemoryConfig] = None,
49
+ ):
50
+ super().__init__(entity_repository)
51
+ self.observation_repository = observation_repository
52
+ self.relation_repository = relation_repository
53
+ self.entity_parser = entity_parser
54
+ self.file_service = file_service
55
+ self.link_resolver = link_resolver
56
+ self.search_service = search_service
57
+ self.app_config = app_config
58
+
59
+ async def detect_file_path_conflicts(
60
+ self, file_path: str, skip_check: bool = False
61
+ ) -> List[Entity]:
62
+ """Detect potential file path conflicts for a given file path.
63
+
64
+ This checks for entities with similar file paths that might cause conflicts:
65
+ - Case sensitivity differences (Finance/file.md vs finance/file.md)
66
+ - Character encoding differences
67
+ - Hyphen vs space differences
68
+ - Unicode normalization differences
69
+
70
+ Args:
71
+ file_path: The file path to check for conflicts
72
+ skip_check: If True, skip the check and return empty list (optimization for bulk operations)
73
+
74
+ Returns:
75
+ List of entities that might conflict with the given file path
76
+ """
77
+ if skip_check:
78
+ return []
79
+
80
+ from basic_memory.utils import detect_potential_file_conflicts
81
+
82
+ conflicts = []
83
+
84
+ # Get all existing file paths
85
+ all_entities = await self.repository.find_all()
86
+ existing_paths = [entity.file_path for entity in all_entities]
87
+
88
+ # Use the enhanced conflict detection utility
89
+ conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
90
+
91
+ # Find the entities corresponding to conflicting paths
92
+ for entity in all_entities:
93
+ if entity.file_path in conflicting_paths:
94
+ conflicts.append(entity)
95
+
96
+ return conflicts
97
+
98
+ async def resolve_permalink(
99
+ self,
100
+ file_path: Permalink | Path,
101
+ markdown: Optional[EntityMarkdown] = None,
102
+ skip_conflict_check: bool = False,
103
+ ) -> str:
104
+ """Get or generate unique permalink for an entity.
105
+
106
+ Priority:
107
+ 1. If markdown has permalink and it's not used by another file -> use as is
108
+ 2. If markdown has permalink but it's used by another file -> make unique
109
+ 3. For existing files, keep current permalink from db
110
+ 4. Generate new unique permalink from file path
111
+
112
+ Enhanced to detect and handle character-related conflicts.
113
+
114
+ Note: Uses lightweight repository methods that skip eager loading of
115
+ observations and relations for better performance during bulk operations.
116
+ """
117
+ file_path_str = Path(file_path).as_posix()
118
+
119
+ # Check for potential file path conflicts before resolving permalink
120
+ conflicts = await self.detect_file_path_conflicts(
121
+ file_path_str, skip_check=skip_conflict_check
122
+ )
123
+ if conflicts:
124
+ logger.warning(
125
+ f"Detected potential file path conflicts for '{file_path_str}': "
126
+ f"{[entity.file_path for entity in conflicts]}"
127
+ )
128
+
129
+ # If markdown has explicit permalink, try to validate it
130
+ if markdown and markdown.frontmatter.permalink:
131
+ desired_permalink = markdown.frontmatter.permalink
132
+ # Use lightweight method - we only need to check file_path
133
+ existing_file_path = await self.repository.get_file_path_for_permalink(
134
+ desired_permalink
135
+ )
136
+
137
+ # If no conflict or it's our own file, use as is
138
+ if not existing_file_path or existing_file_path == file_path_str:
139
+ return desired_permalink
140
+
141
+ # For existing files, try to find current permalink
142
+ # Use lightweight method - we only need the permalink
143
+ existing_permalink = await self.repository.get_permalink_for_file_path(file_path_str)
144
+ if existing_permalink:
145
+ return existing_permalink
146
+
147
+ # New file - generate permalink
148
+ if markdown and markdown.frontmatter.permalink:
149
+ desired_permalink = markdown.frontmatter.permalink
150
+ else:
151
+ desired_permalink = generate_permalink(file_path_str)
152
+
153
+ # Make unique if needed - enhanced to handle character conflicts
154
+ # Use lightweight existence check instead of loading full entity
155
+ permalink = desired_permalink
156
+ suffix = 1
157
+ while await self.repository.permalink_exists(permalink):
158
+ permalink = f"{desired_permalink}-{suffix}"
159
+ suffix += 1
160
+ logger.debug(f"creating unique permalink: {permalink}")
161
+
162
+ return permalink
163
+
164
+ async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]:
165
+ """Create new entity or update existing one.
166
+ Returns: (entity, is_new) where is_new is True if a new entity was created
167
+ """
168
+ logger.debug(
169
+ f"Creating or updating entity: {schema.file_path}, permalink: {schema.permalink}"
170
+ )
171
+
172
+ # Try to find existing entity using strict resolution (no fuzzy search)
173
+ # This prevents incorrectly matching similar file paths like "Node A.md" and "Node C.md"
174
+ existing = await self.link_resolver.resolve_link(schema.file_path, strict=True)
175
+ if not existing and schema.permalink:
176
+ existing = await self.link_resolver.resolve_link(schema.permalink, strict=True)
177
+
178
+ if existing:
179
+ logger.debug(f"Found existing entity: {existing.file_path}")
180
+ return await self.update_entity(existing, schema), False
181
+ else:
182
+ # Create new entity
183
+ return await self.create_entity(schema), True
184
+
185
+ async def create_entity(self, schema: EntitySchema) -> EntityModel:
186
+ """Create a new entity and write to filesystem."""
187
+ logger.debug(f"Creating entity: {schema.title}")
188
+
189
+ # Get file path and ensure it's a Path object
190
+ file_path = Path(schema.file_path)
191
+
192
+ if await self.file_service.exists(file_path):
193
+ raise EntityCreationError(
194
+ f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
195
+ )
196
+
197
+ # Parse content frontmatter to check for user-specified permalink and entity_type
198
+ content_markdown = None
199
+ if schema.content and has_frontmatter(schema.content):
200
+ content_frontmatter = parse_frontmatter(schema.content)
201
+
202
+ # If content has entity_type/type, use it to override the schema entity_type
203
+ if "type" in content_frontmatter:
204
+ schema.entity_type = content_frontmatter["type"]
205
+
206
+ if "permalink" in content_frontmatter:
207
+ # Create a minimal EntityMarkdown object for permalink resolution
208
+ from basic_memory.markdown.schemas import EntityFrontmatter
209
+
210
+ frontmatter_metadata = {
211
+ "title": schema.title,
212
+ "type": schema.entity_type,
213
+ "permalink": content_frontmatter["permalink"],
214
+ }
215
+ frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
216
+ content_markdown = EntityMarkdown(
217
+ frontmatter=frontmatter_obj,
218
+ content="", # content not needed for permalink resolution
219
+ observations=[],
220
+ relations=[],
221
+ )
222
+
223
+ # Get unique permalink (prioritizing content frontmatter) unless disabled
224
+ if self.app_config and self.app_config.disable_permalinks:
225
+ # Use empty string as sentinel to indicate permalinks are disabled
226
+ # The permalink property will return None when it sees empty string
227
+ schema._permalink = ""
228
+ else:
229
+ # Generate and set permalink
230
+ permalink = await self.resolve_permalink(file_path, content_markdown)
231
+ schema._permalink = permalink
232
+
233
+ post = await schema_to_markdown(schema)
234
+
235
+ # write file
236
+ final_content = dump_frontmatter(post)
237
+ checksum = await self.file_service.write_file(file_path, final_content)
238
+
239
+ # parse entity from content we just wrote (avoids re-reading file for cloud compatibility)
240
+ entity_markdown = await self.entity_parser.parse_markdown_content(
241
+ file_path=file_path,
242
+ content=final_content,
243
+ )
244
+
245
+ # create entity
246
+ created = await self.create_entity_from_markdown(file_path, entity_markdown)
247
+
248
+ # add relations
249
+ entity = await self.update_entity_relations(created.file_path, entity_markdown)
250
+
251
+ # Set final checksum to mark complete
252
+ return await self.repository.update(entity.id, {"checksum": checksum})
253
+
254
+ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel:
255
+ """Update an entity's content and metadata."""
256
+ logger.debug(
257
+ f"Updating entity with permalink: {entity.permalink} content-type: {schema.content_type}"
258
+ )
259
+
260
+ # Convert file path string to Path
261
+ file_path = Path(entity.file_path)
262
+
263
+ # Read existing content via file_service (for cloud compatibility)
264
+ existing_content = await self.file_service.read_file_content(file_path)
265
+ existing_markdown = await self.entity_parser.parse_markdown_content(
266
+ file_path=file_path,
267
+ content=existing_content,
268
+ )
269
+
270
+ # Parse content frontmatter to check for user-specified permalink and entity_type
271
+ content_markdown = None
272
+ if schema.content and has_frontmatter(schema.content):
273
+ content_frontmatter = parse_frontmatter(schema.content)
274
+
275
+ # If content has entity_type/type, use it to override the schema entity_type
276
+ if "type" in content_frontmatter:
277
+ schema.entity_type = content_frontmatter["type"]
278
+
279
+ if "permalink" in content_frontmatter:
280
+ # Create a minimal EntityMarkdown object for permalink resolution
281
+ from basic_memory.markdown.schemas import EntityFrontmatter
282
+
283
+ frontmatter_metadata = {
284
+ "title": schema.title,
285
+ "type": schema.entity_type,
286
+ "permalink": content_frontmatter["permalink"],
287
+ }
288
+ frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
289
+ content_markdown = EntityMarkdown(
290
+ frontmatter=frontmatter_obj,
291
+ content="", # content not needed for permalink resolution
292
+ observations=[],
293
+ relations=[],
294
+ )
295
+
296
+ # Check if we need to update the permalink based on content frontmatter (unless disabled)
297
+ new_permalink = entity.permalink # Default to existing
298
+ if self.app_config and not self.app_config.disable_permalinks:
299
+ if content_markdown and content_markdown.frontmatter.permalink:
300
+ # Resolve permalink with the new content frontmatter
301
+ resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
302
+ if resolved_permalink != entity.permalink:
303
+ new_permalink = resolved_permalink
304
+ # Update the schema to use the new permalink
305
+ schema._permalink = new_permalink
306
+
307
+ # Create post with new content from schema
308
+ post = await schema_to_markdown(schema)
309
+
310
+ # Merge new metadata with existing metadata
311
+ existing_markdown.frontmatter.metadata.update(post.metadata)
312
+
313
+ # Ensure the permalink in the metadata is the resolved one
314
+ if new_permalink != entity.permalink:
315
+ existing_markdown.frontmatter.metadata["permalink"] = new_permalink
316
+
317
+ # Create a new post with merged metadata
318
+ merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
319
+
320
+ # write file
321
+ final_content = dump_frontmatter(merged_post)
322
+ checksum = await self.file_service.write_file(file_path, final_content)
323
+
324
+ # parse entity from content we just wrote (avoids re-reading file for cloud compatibility)
325
+ entity_markdown = await self.entity_parser.parse_markdown_content(
326
+ file_path=file_path,
327
+ content=final_content,
328
+ )
329
+
330
+ # update entity in db
331
+ entity = await self.update_entity_and_observations(file_path, entity_markdown)
332
+
333
+ # add relations
334
+ await self.update_entity_relations(file_path.as_posix(), entity_markdown)
335
+
336
+ # Set final checksum to match file
337
+ entity = await self.repository.update(entity.id, {"checksum": checksum})
338
+
339
+ return entity
340
+
341
+ async def delete_entity(self, permalink_or_id: str | int) -> bool:
342
+ """Delete entity and its file."""
343
+ logger.debug(f"Deleting entity: {permalink_or_id}")
344
+
345
+ try:
346
+ # Get entity first for file deletion
347
+ if isinstance(permalink_or_id, str):
348
+ entity = await self.get_by_permalink(permalink_or_id)
349
+ else:
350
+ entities = await self.get_entities_by_id([permalink_or_id])
351
+ if len(entities) != 1: # pragma: no cover
352
+ logger.error(
353
+ "Entity lookup error", entity_id=permalink_or_id, found_count=len(entities)
354
+ )
355
+ raise ValueError(
356
+ f"Expected 1 entity with ID {permalink_or_id}, got {len(entities)}"
357
+ )
358
+ entity = entities[0]
359
+
360
+ # Delete from search index first (if search_service is available)
361
+ if self.search_service:
362
+ await self.search_service.handle_delete(entity)
363
+
364
+ # Delete file
365
+ await self.file_service.delete_entity_file(entity)
366
+
367
+ # Delete from DB (this will cascade to observations/relations)
368
+ return await self.repository.delete(entity.id)
369
+
370
+ except EntityNotFoundError:
371
+ logger.info(f"Entity not found: {permalink_or_id}")
372
+ return True # Already deleted
373
+
374
+ async def get_by_permalink(self, permalink: str) -> EntityModel:
375
+ """Get entity by type and name combination."""
376
+ logger.debug(f"Getting entity by permalink: {permalink}")
377
+ db_entity = await self.repository.get_by_permalink(permalink)
378
+ if not db_entity:
379
+ raise EntityNotFoundError(f"Entity not found: {permalink}")
380
+ return db_entity
381
+
382
+ async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
383
+ """Get specific entities and their relationships."""
384
+ logger.debug(f"Getting entities: {ids}")
385
+ return await self.repository.find_by_ids(ids)
386
+
387
+ async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
388
+ """Get specific nodes and their relationships."""
389
+ logger.debug(f"Getting entities permalinks: {permalinks}")
390
+ return await self.repository.find_by_permalinks(permalinks)
391
+
392
+ async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None:
393
+ """Delete entity by file path."""
394
+ await self.repository.delete_by_file_path(str(file_path))
395
+
396
+ async def create_entity_from_markdown(
397
+ self, file_path: Path, markdown: EntityMarkdown
398
+ ) -> EntityModel:
399
+ """Create entity and observations only.
400
+
401
+ Creates the entity with null checksum to indicate sync not complete.
402
+ Relations will be added in second pass.
403
+
404
+ Uses UPSERT approach to handle permalink/file_path conflicts cleanly.
405
+ """
406
+ logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
407
+ model = entity_model_from_markdown(
408
+ file_path, markdown, project_id=self.repository.project_id
409
+ )
410
+
411
+ # Mark as incomplete because we still need to add relations
412
+ model.checksum = None
413
+
414
+ # Use UPSERT to handle conflicts cleanly
415
+ try:
416
+ return await self.repository.upsert_entity(model)
417
+ except Exception as e:
418
+ logger.error(f"Failed to upsert entity for {file_path}: {e}")
419
+ raise EntityCreationError(f"Failed to create entity: {str(e)}") from e
420
+
421
+ async def update_entity_and_observations(
422
+ self, file_path: Path, markdown: EntityMarkdown
423
+ ) -> EntityModel:
424
+ """Update entity fields and observations.
425
+
426
+ Updates everything except relations and sets null checksum
427
+ to indicate sync not complete.
428
+ """
429
+ logger.debug(f"Updating entity and observations: {file_path}")
430
+
431
+ db_entity = await self.repository.get_by_file_path(file_path.as_posix())
432
+
433
+ # Clear observations for entity
434
+ await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
435
+
436
+ # add new observations
437
+ observations = [
438
+ Observation(
439
+ project_id=self.observation_repository.project_id,
440
+ entity_id=db_entity.id,
441
+ content=obs.content,
442
+ category=obs.category,
443
+ context=obs.context,
444
+ tags=obs.tags,
445
+ )
446
+ for obs in markdown.observations
447
+ ]
448
+ await self.observation_repository.add_all(observations)
449
+
450
+ # update values from markdown
451
+ db_entity = entity_model_from_markdown(file_path, markdown, db_entity)
452
+
453
+ # checksum value is None == not finished with sync
454
+ db_entity.checksum = None
455
+
456
+ # update entity
457
+ return await self.repository.update(
458
+ db_entity.id,
459
+ db_entity,
460
+ )
461
+
462
+ async def update_entity_relations(
463
+ self,
464
+ path: str,
465
+ markdown: EntityMarkdown,
466
+ ) -> EntityModel:
467
+ """Update relations for entity"""
468
+ logger.debug(f"Updating relations for entity: {path}")
469
+
470
+ db_entity = await self.repository.get_by_file_path(path)
471
+
472
+ # Clear existing relations first
473
+ await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
474
+
475
+ # Batch resolve all relation targets in parallel
476
+ if markdown.relations:
477
+ import asyncio
478
+
479
+ # Create tasks for all relation lookups
480
+ # Use strict=True to disable fuzzy search - only exact matches should create resolved relations
481
+ # This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
482
+ lookup_tasks = [
483
+ self.link_resolver.resolve_link(rel.target, strict=True)
484
+ for rel in markdown.relations
485
+ ]
486
+
487
+ # Execute all lookups in parallel
488
+ resolved_entities = await asyncio.gather(*lookup_tasks, return_exceptions=True)
489
+
490
+ # Process results and create relation records
491
+ relations_to_add = []
492
+ for rel, resolved in zip(markdown.relations, resolved_entities):
493
+ # Handle exceptions from gather and None results
494
+ target_entity: Optional[Entity] = None
495
+ if not isinstance(resolved, Exception):
496
+ # Type narrowing: resolved is Optional[Entity] here, not Exception
497
+ target_entity = resolved # type: ignore
498
+
499
+ # if the target is found, store the id
500
+ target_id = target_entity.id if target_entity else None
501
+ # if the target is found, store the title, otherwise add the target for a "forward link"
502
+ target_name = target_entity.title if target_entity else rel.target
503
+
504
+ # Create the relation
505
+ relation = Relation(
506
+ project_id=self.relation_repository.project_id,
507
+ from_id=db_entity.id,
508
+ to_id=target_id,
509
+ to_name=target_name,
510
+ relation_type=rel.type,
511
+ context=rel.context,
512
+ )
513
+ relations_to_add.append(relation)
514
+
515
+ # Batch insert all relations
516
+ if relations_to_add:
517
+ try:
518
+ await self.relation_repository.add_all(relations_to_add)
519
+ except IntegrityError:
520
+ # Some relations might be duplicates - fall back to individual inserts
521
+ logger.debug("Batch relation insert failed, trying individual inserts")
522
+ for relation in relations_to_add:
523
+ try:
524
+ await self.relation_repository.add(relation)
525
+ except IntegrityError:
526
+ # Unique constraint violation - relation already exists
527
+ logger.debug(
528
+ f"Skipping duplicate relation {relation.relation_type} from {db_entity.permalink}"
529
+ )
530
+ continue
531
+
532
+ return await self.repository.get_by_file_path(path)
533
+
534
+ async def edit_entity(
535
+ self,
536
+ identifier: str,
537
+ operation: str,
538
+ content: str,
539
+ section: Optional[str] = None,
540
+ find_text: Optional[str] = None,
541
+ expected_replacements: int = 1,
542
+ ) -> EntityModel:
543
+ """Edit an existing entity's content using various operations.
544
+
545
+ Args:
546
+ identifier: Entity identifier (permalink, title, etc.)
547
+ operation: The editing operation (append, prepend, find_replace, replace_section)
548
+ content: The content to add or use for replacement
549
+ section: For replace_section operation - the markdown header
550
+ find_text: For find_replace operation - the text to find and replace
551
+ expected_replacements: For find_replace operation - expected number of replacements (default: 1)
552
+
553
+ Returns:
554
+ The updated entity model
555
+
556
+ Raises:
557
+ EntityNotFoundError: If the entity cannot be found
558
+ ValueError: If required parameters are missing for the operation or replacement count doesn't match expected
559
+ """
560
+ logger.debug(f"Editing entity: {identifier}, operation: {operation}")
561
+
562
+ # Find the entity using the link resolver with strict mode for destructive operations
563
+ entity = await self.link_resolver.resolve_link(identifier, strict=True)
564
+ if not entity:
565
+ raise EntityNotFoundError(f"Entity not found: {identifier}")
566
+
567
+ # Read the current file content
568
+ file_path = Path(entity.file_path)
569
+ current_content, _ = await self.file_service.read_file(file_path)
570
+
571
+ # Apply the edit operation
572
+ new_content = self.apply_edit_operation(
573
+ current_content, operation, content, section, find_text, expected_replacements
574
+ )
575
+
576
+ # Write the updated content back to the file
577
+ checksum = await self.file_service.write_file(file_path, new_content)
578
+
579
+ # Parse the content we just wrote (avoids re-reading file for cloud compatibility)
580
+ entity_markdown = await self.entity_parser.parse_markdown_content(
581
+ file_path=file_path,
582
+ content=new_content,
583
+ )
584
+
585
+ # Update entity and its relationships
586
+ entity = await self.update_entity_and_observations(file_path, entity_markdown)
587
+ await self.update_entity_relations(file_path.as_posix(), entity_markdown)
588
+
589
+ # Set final checksum to match file
590
+ entity = await self.repository.update(entity.id, {"checksum": checksum})
591
+
592
+ return entity
593
+
594
+ def apply_edit_operation(
595
+ self,
596
+ current_content: str,
597
+ operation: str,
598
+ content: str,
599
+ section: Optional[str] = None,
600
+ find_text: Optional[str] = None,
601
+ expected_replacements: int = 1,
602
+ ) -> str:
603
+ """Apply the specified edit operation to the current content."""
604
+
605
+ if operation == "append":
606
+ # Ensure proper spacing
607
+ if current_content and not current_content.endswith("\n"):
608
+ return current_content + "\n" + content
609
+ return current_content + content # pragma: no cover
610
+
611
+ elif operation == "prepend":
612
+ # Handle frontmatter-aware prepending
613
+ return self._prepend_after_frontmatter(current_content, content)
614
+
615
+ elif operation == "find_replace":
616
+ if not find_text:
617
+ raise ValueError("find_text is required for find_replace operation")
618
+ if not find_text.strip():
619
+ raise ValueError("find_text cannot be empty or whitespace only")
620
+
621
+ # Count actual occurrences
622
+ actual_count = current_content.count(find_text)
623
+
624
+ # Validate count matches expected
625
+ if actual_count != expected_replacements:
626
+ if actual_count == 0:
627
+ raise ValueError(f"Text to replace not found: '{find_text}'")
628
+ else:
629
+ raise ValueError(
630
+ f"Expected {expected_replacements} occurrences of '{find_text}', "
631
+ f"but found {actual_count}"
632
+ )
633
+
634
+ return current_content.replace(find_text, content)
635
+
636
+ elif operation == "replace_section":
637
+ if not section:
638
+ raise ValueError("section is required for replace_section operation")
639
+ if not section.strip():
640
+ raise ValueError("section cannot be empty or whitespace only")
641
+ return self.replace_section_content(current_content, section, content)
642
+
643
+ else:
644
+ raise ValueError(f"Unsupported operation: {operation}")
645
+
646
+ def replace_section_content(
647
+ self, current_content: str, section_header: str, new_content: str
648
+ ) -> str:
649
+ """Replace content under a specific markdown section header.
650
+
651
+ This method uses a simple, safe approach: when replacing a section, it only
652
+ replaces the immediate content under that header until it encounters the next
653
+ header of ANY level. This means:
654
+
655
+ - Replacing "# Header" replaces content until "## Subsection" (preserves subsections)
656
+ - Replacing "## Section" replaces content until "### Subsection" (preserves subsections)
657
+ - More predictable and safer than trying to consume entire hierarchies
658
+
659
+ Args:
660
+ current_content: The current markdown content
661
+ section_header: The section header to find and replace (e.g., "## Section Name")
662
+ new_content: The new content to replace the section with (should not include the header itself)
663
+
664
+ Returns:
665
+ The updated content with the section replaced
666
+
667
+ Raises:
668
+ ValueError: If multiple sections with the same header are found
669
+ """
670
+ # Normalize the section header (ensure it starts with #)
671
+ if not section_header.startswith("#"):
672
+ section_header = "## " + section_header
673
+
674
+ # Strip duplicate header from new_content if present (fix for issue #390)
675
+ # LLMs sometimes include the section header in their content, which would create duplicates
676
+ new_content_lines = new_content.lstrip().split("\n")
677
+ if new_content_lines and new_content_lines[0].strip() == section_header.strip():
678
+ # Remove the duplicate header line
679
+ new_content = "\n".join(new_content_lines[1:]).lstrip()
680
+
681
+ # First pass: count matching sections to check for duplicates
682
+ lines = current_content.split("\n")
683
+ matching_sections = []
684
+
685
+ for i, line in enumerate(lines):
686
+ if line.strip() == section_header.strip():
687
+ matching_sections.append(i)
688
+
689
+ # Handle multiple sections error
690
+ if len(matching_sections) > 1:
691
+ raise ValueError(
692
+ f"Multiple sections found with header '{section_header}'. "
693
+ f"Section replacement requires unique headers."
694
+ )
695
+
696
+ # If no section found, append it
697
+ if len(matching_sections) == 0:
698
+ logger.info(f"Section '{section_header}' not found, appending to end of document")
699
+ separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
700
+ return current_content + separator + section_header + "\n" + new_content
701
+
702
+ # Replace the single matching section
703
+ result_lines = []
704
+ section_line_idx = matching_sections[0]
705
+
706
+ i = 0
707
+ while i < len(lines):
708
+ line = lines[i]
709
+
710
+ # Check if this is our target section header
711
+ if i == section_line_idx:
712
+ # Add the section header and new content
713
+ result_lines.append(line)
714
+ result_lines.append(new_content)
715
+ i += 1
716
+
717
+ # Skip the original section content until next header or end
718
+ while i < len(lines):
719
+ next_line = lines[i]
720
+ # Stop consuming when we hit any header (preserve subsections)
721
+ if next_line.startswith("#"):
722
+ # We found another header - continue processing from here
723
+ break
724
+ i += 1
725
+ # Continue processing from the next header (don't increment i again)
726
+ continue
727
+
728
+ # Add all other lines (including subsequent sections)
729
+ result_lines.append(line)
730
+ i += 1
731
+
732
+ return "\n".join(result_lines)
733
+
734
+ def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
735
+ """Prepend content after frontmatter, preserving frontmatter structure."""
736
+
737
+ # Check if file has frontmatter
738
+ if has_frontmatter(current_content):
739
+ try:
740
+ # Parse and separate frontmatter from body
741
+ frontmatter_data = parse_frontmatter(current_content)
742
+ body_content = remove_frontmatter(current_content)
743
+
744
+ # Prepend content to the body
745
+ if content and not content.endswith("\n"):
746
+ new_body = content + "\n" + body_content
747
+ else:
748
+ new_body = content + body_content
749
+
750
+ # Reconstruct file with frontmatter + prepended body
751
+ yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
752
+ return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
753
+
754
+ except Exception as e: # pragma: no cover
755
+ logger.warning(
756
+ f"Failed to parse frontmatter during prepend: {e}"
757
+ ) # pragma: no cover
758
+ # Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
759
+
760
+ # No frontmatter or parsing failed - do simple prepend # pragma: no cover
761
+ if content and not content.endswith("\n"): # pragma: no cover
762
+ return content + "\n" + current_content # pragma: no cover
763
+ return content + current_content # pragma: no cover
764
+
765
+ async def move_entity(
766
+ self,
767
+ identifier: str,
768
+ destination_path: str,
769
+ project_config: ProjectConfig,
770
+ app_config: BasicMemoryConfig,
771
+ ) -> EntityModel:
772
+ """Move entity to new location with database consistency.
773
+
774
+ Args:
775
+ identifier: Entity identifier (title, permalink, or memory:// URL)
776
+ destination_path: New path relative to project root
777
+ project_config: Project configuration for file operations
778
+ app_config: App configuration for permalink update settings
779
+
780
+ Returns:
781
+ Success message with move details
782
+
783
+ Raises:
784
+ EntityNotFoundError: If the entity cannot be found
785
+ ValueError: If move operation fails due to validation or filesystem errors
786
+ """
787
+ logger.debug(f"Moving entity: {identifier} to {destination_path}")
788
+
789
+ # 1. Resolve identifier to entity with strict mode for destructive operations
790
+ entity = await self.link_resolver.resolve_link(identifier, strict=True)
791
+ if not entity:
792
+ raise EntityNotFoundError(f"Entity not found: {identifier}")
793
+
794
+ current_path = entity.file_path
795
+ old_permalink = entity.permalink
796
+
797
+ # 2. Validate destination path format first
798
+ if not destination_path or destination_path.startswith("/") or not destination_path.strip():
799
+ raise ValueError(f"Invalid destination path: {destination_path}")
800
+
801
+ # 3. Validate paths
802
+ # NOTE: In tenantless/cloud mode, we cannot rely on local filesystem paths.
803
+ # Use FileService for existence checks and moving.
804
+ if not await self.file_service.exists(current_path):
805
+ raise ValueError(f"Source file not found: {current_path}")
806
+
807
+ if await self.file_service.exists(destination_path):
808
+ raise ValueError(f"Destination already exists: {destination_path}")
809
+
810
+ try:
811
+ # 4. Ensure destination directory if needed (no-op for S3)
812
+ await self.file_service.ensure_directory(Path(destination_path).parent)
813
+
814
+ # 5. Move physical file via FileService (filesystem rename or cloud move)
815
+ await self.file_service.move_file(current_path, destination_path)
816
+ logger.info(f"Moved file: {current_path} -> {destination_path}")
817
+
818
+ # 6. Prepare database updates
819
+ updates = {"file_path": destination_path}
820
+
821
+ # 7. Update permalink if configured or if entity has null permalink (unless disabled)
822
+ if not app_config.disable_permalinks and (
823
+ app_config.update_permalinks_on_move or old_permalink is None
824
+ ):
825
+ # Generate new permalink from destination path
826
+ new_permalink = await self.resolve_permalink(destination_path)
827
+
828
+ # Update frontmatter with new permalink
829
+ await self.file_service.update_frontmatter(
830
+ destination_path, {"permalink": new_permalink}
831
+ )
832
+
833
+ updates["permalink"] = new_permalink
834
+ if old_permalink is None:
835
+ logger.info(
836
+ f"Generated permalink for entity with null permalink: {new_permalink}"
837
+ )
838
+ else:
839
+ logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
840
+
841
+ # 8. Recalculate checksum
842
+ new_checksum = await self.file_service.compute_checksum(destination_path)
843
+ updates["checksum"] = new_checksum
844
+
845
+ # 9. Update database
846
+ updated_entity = await self.repository.update(entity.id, updates)
847
+ if not updated_entity:
848
+ raise ValueError(f"Failed to update entity in database: {entity.id}")
849
+
850
+ return updated_entity
851
+
852
+ except Exception as e:
853
+ # Rollback: try to restore original file location if move succeeded
854
+ try:
855
+ if await self.file_service.exists(
856
+ destination_path
857
+ ) and not await self.file_service.exists(current_path):
858
+ await self.file_service.move_file(destination_path, current_path)
859
+ logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
860
+ except Exception as rollback_error: # pragma: no cover
861
+ logger.error(f"Failed to rollback file move: {rollback_error}")
862
+
863
+ # Re-raise the original error with context
864
+ raise ValueError(f"Move failed: {str(e)}") from e