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