basic-memory 0.12.3__py3-none-any.whl → 0.13.0b1__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 (107) hide show
  1. basic_memory/__init__.py +7 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
  5. basic_memory/api/app.py +43 -13
  6. basic_memory/api/routers/__init__.py +4 -2
  7. basic_memory/api/routers/directory_router.py +63 -0
  8. basic_memory/api/routers/importer_router.py +152 -0
  9. basic_memory/api/routers/knowledge_router.py +127 -38
  10. basic_memory/api/routers/management_router.py +78 -0
  11. basic_memory/api/routers/memory_router.py +4 -59
  12. basic_memory/api/routers/project_router.py +230 -0
  13. basic_memory/api/routers/prompt_router.py +260 -0
  14. basic_memory/api/routers/search_router.py +3 -21
  15. basic_memory/api/routers/utils.py +130 -0
  16. basic_memory/api/template_loader.py +292 -0
  17. basic_memory/cli/app.py +20 -21
  18. basic_memory/cli/commands/__init__.py +2 -1
  19. basic_memory/cli/commands/auth.py +136 -0
  20. basic_memory/cli/commands/db.py +3 -3
  21. basic_memory/cli/commands/import_chatgpt.py +31 -207
  22. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  23. basic_memory/cli/commands/import_claude_projects.py +33 -143
  24. basic_memory/cli/commands/import_memory_json.py +26 -83
  25. basic_memory/cli/commands/mcp.py +71 -18
  26. basic_memory/cli/commands/project.py +99 -67
  27. basic_memory/cli/commands/status.py +19 -9
  28. basic_memory/cli/commands/sync.py +44 -58
  29. basic_memory/cli/main.py +1 -5
  30. basic_memory/config.py +145 -88
  31. basic_memory/db.py +6 -4
  32. basic_memory/deps.py +227 -30
  33. basic_memory/importers/__init__.py +27 -0
  34. basic_memory/importers/base.py +79 -0
  35. basic_memory/importers/chatgpt_importer.py +222 -0
  36. basic_memory/importers/claude_conversations_importer.py +172 -0
  37. basic_memory/importers/claude_projects_importer.py +148 -0
  38. basic_memory/importers/memory_json_importer.py +93 -0
  39. basic_memory/importers/utils.py +58 -0
  40. basic_memory/markdown/entity_parser.py +5 -2
  41. basic_memory/mcp/auth_provider.py +270 -0
  42. basic_memory/mcp/external_auth_provider.py +321 -0
  43. basic_memory/mcp/project_session.py +103 -0
  44. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  45. basic_memory/mcp/prompts/recent_activity.py +19 -3
  46. basic_memory/mcp/prompts/search.py +14 -140
  47. basic_memory/mcp/prompts/utils.py +3 -3
  48. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  49. basic_memory/mcp/server.py +82 -8
  50. basic_memory/mcp/supabase_auth_provider.py +463 -0
  51. basic_memory/mcp/tools/__init__.py +20 -0
  52. basic_memory/mcp/tools/build_context.py +11 -1
  53. basic_memory/mcp/tools/canvas.py +15 -2
  54. basic_memory/mcp/tools/delete_note.py +12 -4
  55. basic_memory/mcp/tools/edit_note.py +297 -0
  56. basic_memory/mcp/tools/list_directory.py +154 -0
  57. basic_memory/mcp/tools/move_note.py +87 -0
  58. basic_memory/mcp/tools/project_management.py +300 -0
  59. basic_memory/mcp/tools/read_content.py +15 -6
  60. basic_memory/mcp/tools/read_note.py +17 -5
  61. basic_memory/mcp/tools/recent_activity.py +11 -2
  62. basic_memory/mcp/tools/search.py +10 -1
  63. basic_memory/mcp/tools/utils.py +137 -12
  64. basic_memory/mcp/tools/write_note.py +11 -15
  65. basic_memory/models/__init__.py +3 -2
  66. basic_memory/models/knowledge.py +16 -4
  67. basic_memory/models/project.py +80 -0
  68. basic_memory/models/search.py +8 -5
  69. basic_memory/repository/__init__.py +2 -0
  70. basic_memory/repository/entity_repository.py +8 -3
  71. basic_memory/repository/observation_repository.py +35 -3
  72. basic_memory/repository/project_info_repository.py +3 -2
  73. basic_memory/repository/project_repository.py +85 -0
  74. basic_memory/repository/relation_repository.py +8 -2
  75. basic_memory/repository/repository.py +107 -15
  76. basic_memory/repository/search_repository.py +87 -27
  77. basic_memory/schemas/__init__.py +6 -0
  78. basic_memory/schemas/directory.py +30 -0
  79. basic_memory/schemas/importer.py +34 -0
  80. basic_memory/schemas/memory.py +26 -12
  81. basic_memory/schemas/project_info.py +112 -2
  82. basic_memory/schemas/prompt.py +90 -0
  83. basic_memory/schemas/request.py +56 -2
  84. basic_memory/schemas/search.py +1 -1
  85. basic_memory/services/__init__.py +2 -1
  86. basic_memory/services/context_service.py +208 -95
  87. basic_memory/services/directory_service.py +167 -0
  88. basic_memory/services/entity_service.py +385 -5
  89. basic_memory/services/exceptions.py +6 -0
  90. basic_memory/services/file_service.py +14 -15
  91. basic_memory/services/initialization.py +144 -67
  92. basic_memory/services/link_resolver.py +16 -8
  93. basic_memory/services/project_service.py +548 -0
  94. basic_memory/services/search_service.py +77 -2
  95. basic_memory/sync/background_sync.py +25 -0
  96. basic_memory/sync/sync_service.py +10 -9
  97. basic_memory/sync/watch_service.py +63 -39
  98. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  99. basic_memory/templates/prompts/search.hbs +101 -0
  100. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/METADATA +23 -1
  101. basic_memory-0.13.0b1.dist-info/RECORD +132 -0
  102. basic_memory/api/routers/project_info_router.py +0 -274
  103. basic_memory/mcp/main.py +0 -24
  104. basic_memory-0.12.3.dist-info/RECORD +0 -100
  105. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/WHEEL +0 -0
  106. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/entry_points.txt +0 -0
  107. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -4,9 +4,12 @@ from pathlib import Path
4
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 has_frontmatter, parse_frontmatter, remove_frontmatter
10
13
  from basic_memory.markdown import EntityMarkdown
11
14
  from basic_memory.markdown.entity_parser import EntityParser
12
15
  from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
@@ -86,13 +89,17 @@ class EntityService(BaseService[EntityModel]):
86
89
  """Create new entity or update existing one.
87
90
  Returns: (entity, is_new) where is_new is True if a new entity was created
88
91
  """
89
- logger.debug(f"Creating or updating entity: {schema}")
92
+ logger.debug(
93
+ f"Creating or updating entity: {schema.file_path}, permalink: {schema.permalink}"
94
+ )
90
95
 
91
96
  # Try to find existing entity using smart resolution
92
- existing = await self.link_resolver.resolve_link(schema.permalink or schema.file_path)
97
+ existing = await self.link_resolver.resolve_link(
98
+ schema.file_path
99
+ ) or await self.link_resolver.resolve_link(schema.permalink)
93
100
 
94
101
  if existing:
95
- logger.debug(f"Found existing entity: {existing.permalink}")
102
+ logger.debug(f"Found existing entity: {existing.file_path}")
96
103
  return await self.update_entity(existing, schema), False
97
104
  else:
98
105
  # Create new entity
@@ -110,8 +117,29 @@ class EntityService(BaseService[EntityModel]):
110
117
  f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
111
118
  )
112
119
 
113
- # Get unique permalink
114
- permalink = await self.resolve_permalink(schema.permalink or file_path)
120
+ # Parse content frontmatter to check for user-specified permalink
121
+ content_markdown = None
122
+ if schema.content and has_frontmatter(schema.content):
123
+ content_frontmatter = parse_frontmatter(schema.content)
124
+ if "permalink" in content_frontmatter:
125
+ # Create a minimal EntityMarkdown object for permalink resolution
126
+ from basic_memory.markdown.schemas import EntityFrontmatter
127
+
128
+ frontmatter_metadata = {
129
+ "title": schema.title,
130
+ "type": schema.entity_type,
131
+ "permalink": content_frontmatter["permalink"],
132
+ }
133
+ frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
134
+ content_markdown = EntityMarkdown(
135
+ frontmatter=frontmatter_obj,
136
+ content="", # content not needed for permalink resolution
137
+ observations=[],
138
+ relations=[],
139
+ )
140
+
141
+ # Get unique permalink (prioritizing content frontmatter)
142
+ permalink = await self.resolve_permalink(file_path, content_markdown)
115
143
  schema._permalink = permalink
116
144
 
117
145
  post = await schema_to_markdown(schema)
@@ -144,12 +172,47 @@ class EntityService(BaseService[EntityModel]):
144
172
  # Read existing frontmatter from the file if it exists
145
173
  existing_markdown = await self.entity_parser.parse_file(file_path)
146
174
 
175
+ # Parse content frontmatter to check for user-specified permalink
176
+ content_markdown = None
177
+ if schema.content and has_frontmatter(schema.content):
178
+ content_frontmatter = parse_frontmatter(schema.content)
179
+ if "permalink" in content_frontmatter:
180
+ # Create a minimal EntityMarkdown object for permalink resolution
181
+ from basic_memory.markdown.schemas import EntityFrontmatter
182
+
183
+ frontmatter_metadata = {
184
+ "title": schema.title,
185
+ "type": schema.entity_type,
186
+ "permalink": content_frontmatter["permalink"],
187
+ }
188
+ frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
189
+ content_markdown = EntityMarkdown(
190
+ frontmatter=frontmatter_obj,
191
+ content="", # content not needed for permalink resolution
192
+ observations=[],
193
+ relations=[],
194
+ )
195
+
196
+ # Check if we need to update the permalink based on content frontmatter
197
+ new_permalink = entity.permalink # Default to existing
198
+ if content_markdown and content_markdown.frontmatter.permalink:
199
+ # Resolve permalink with the new content frontmatter
200
+ resolved_permalink = await self.resolve_permalink(file_path, content_markdown)
201
+ if resolved_permalink != entity.permalink:
202
+ new_permalink = resolved_permalink
203
+ # Update the schema to use the new permalink
204
+ schema._permalink = new_permalink
205
+
147
206
  # Create post with new content from schema
148
207
  post = await schema_to_markdown(schema)
149
208
 
150
209
  # Merge new metadata with existing metadata
151
210
  existing_markdown.frontmatter.metadata.update(post.metadata)
152
211
 
212
+ # Ensure the permalink in the metadata is the resolved one
213
+ if new_permalink != entity.permalink:
214
+ existing_markdown.frontmatter.metadata["permalink"] = new_permalink
215
+
153
216
  # Create a new post with merged metadata
154
217
  merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
155
218
 
@@ -235,6 +298,7 @@ class EntityService(BaseService[EntityModel]):
235
298
 
236
299
  # Mark as incomplete because we still need to add relations
237
300
  model.checksum = None
301
+ # Repository will set project_id automatically
238
302
  return await self.repository.add(model)
239
303
 
240
304
  async def update_entity_and_observations(
@@ -320,3 +384,319 @@ class EntityService(BaseService[EntityModel]):
320
384
  continue
321
385
 
322
386
  return await self.repository.get_by_file_path(path)
387
+
388
+ async def edit_entity(
389
+ self,
390
+ identifier: str,
391
+ operation: str,
392
+ content: str,
393
+ section: Optional[str] = None,
394
+ find_text: Optional[str] = None,
395
+ expected_replacements: int = 1,
396
+ ) -> EntityModel:
397
+ """Edit an existing entity's content using various operations.
398
+
399
+ Args:
400
+ identifier: Entity identifier (permalink, title, etc.)
401
+ operation: The editing operation (append, prepend, find_replace, replace_section)
402
+ content: The content to add or use for replacement
403
+ section: For replace_section operation - the markdown header
404
+ find_text: For find_replace operation - the text to find and replace
405
+ expected_replacements: For find_replace operation - expected number of replacements (default: 1)
406
+
407
+ Returns:
408
+ The updated entity model
409
+
410
+ Raises:
411
+ EntityNotFoundError: If the entity cannot be found
412
+ ValueError: If required parameters are missing for the operation or replacement count doesn't match expected
413
+ """
414
+ logger.debug(f"Editing entity: {identifier}, operation: {operation}")
415
+
416
+ # Find the entity using the link resolver
417
+ entity = await self.link_resolver.resolve_link(identifier)
418
+ if not entity:
419
+ raise EntityNotFoundError(f"Entity not found: {identifier}")
420
+
421
+ # Read the current file content
422
+ file_path = Path(entity.file_path)
423
+ current_content, _ = await self.file_service.read_file(file_path)
424
+
425
+ # Apply the edit operation
426
+ new_content = self.apply_edit_operation(
427
+ current_content, operation, content, section, find_text, expected_replacements
428
+ )
429
+
430
+ # Write the updated content back to the file
431
+ checksum = await self.file_service.write_file(file_path, new_content)
432
+
433
+ # Parse the updated file to get new observations/relations
434
+ entity_markdown = await self.entity_parser.parse_file(file_path)
435
+
436
+ # Update entity and its relationships
437
+ entity = await self.update_entity_and_observations(file_path, entity_markdown)
438
+ await self.update_entity_relations(str(file_path), entity_markdown)
439
+
440
+ # Set final checksum to match file
441
+ entity = await self.repository.update(entity.id, {"checksum": checksum})
442
+
443
+ return entity
444
+
445
+ def apply_edit_operation(
446
+ self,
447
+ current_content: str,
448
+ operation: str,
449
+ content: str,
450
+ section: Optional[str] = None,
451
+ find_text: Optional[str] = None,
452
+ expected_replacements: int = 1,
453
+ ) -> str:
454
+ """Apply the specified edit operation to the current content."""
455
+
456
+ if operation == "append":
457
+ # Ensure proper spacing
458
+ if current_content and not current_content.endswith("\n"):
459
+ return current_content + "\n" + content
460
+ return current_content + content # pragma: no cover
461
+
462
+ elif operation == "prepend":
463
+ # Handle frontmatter-aware prepending
464
+ return self._prepend_after_frontmatter(current_content, content)
465
+
466
+ elif operation == "find_replace":
467
+ if not find_text:
468
+ raise ValueError("find_text is required for find_replace operation")
469
+ if not find_text.strip():
470
+ raise ValueError("find_text cannot be empty or whitespace only")
471
+
472
+ # Count actual occurrences
473
+ actual_count = current_content.count(find_text)
474
+
475
+ # Validate count matches expected
476
+ if actual_count != expected_replacements:
477
+ if actual_count == 0:
478
+ raise ValueError(f"Text to replace not found: '{find_text}'")
479
+ else:
480
+ raise ValueError(
481
+ f"Expected {expected_replacements} occurrences of '{find_text}', "
482
+ f"but found {actual_count}"
483
+ )
484
+
485
+ return current_content.replace(find_text, content)
486
+
487
+ elif operation == "replace_section":
488
+ if not section:
489
+ raise ValueError("section is required for replace_section operation")
490
+ if not section.strip():
491
+ raise ValueError("section cannot be empty or whitespace only")
492
+ return self.replace_section_content(current_content, section, content)
493
+
494
+ else:
495
+ raise ValueError(f"Unsupported operation: {operation}")
496
+
497
+ def replace_section_content(
498
+ self, current_content: str, section_header: str, new_content: str
499
+ ) -> str:
500
+ """Replace content under a specific markdown section header.
501
+
502
+ This method uses a simple, safe approach: when replacing a section, it only
503
+ replaces the immediate content under that header until it encounters the next
504
+ header of ANY level. This means:
505
+
506
+ - Replacing "# Header" replaces content until "## Subsection" (preserves subsections)
507
+ - Replacing "## Section" replaces content until "### Subsection" (preserves subsections)
508
+ - More predictable and safer than trying to consume entire hierarchies
509
+
510
+ Args:
511
+ current_content: The current markdown content
512
+ section_header: The section header to find and replace (e.g., "## Section Name")
513
+ new_content: The new content to replace the section with
514
+
515
+ Returns:
516
+ The updated content with the section replaced
517
+
518
+ Raises:
519
+ ValueError: If multiple sections with the same header are found
520
+ """
521
+ # Normalize the section header (ensure it starts with #)
522
+ if not section_header.startswith("#"):
523
+ section_header = "## " + section_header
524
+
525
+ # First pass: count matching sections to check for duplicates
526
+ lines = current_content.split("\n")
527
+ matching_sections = []
528
+
529
+ for i, line in enumerate(lines):
530
+ if line.strip() == section_header.strip():
531
+ matching_sections.append(i)
532
+
533
+ # Handle multiple sections error
534
+ if len(matching_sections) > 1:
535
+ raise ValueError(
536
+ f"Multiple sections found with header '{section_header}'. "
537
+ f"Section replacement requires unique headers."
538
+ )
539
+
540
+ # If no section found, append it
541
+ if len(matching_sections) == 0:
542
+ logger.info(f"Section '{section_header}' not found, appending to end of document")
543
+ separator = "\n\n" if current_content and not current_content.endswith("\n\n") else ""
544
+ return current_content + separator + section_header + "\n" + new_content
545
+
546
+ # Replace the single matching section
547
+ result_lines = []
548
+ section_line_idx = matching_sections[0]
549
+
550
+ i = 0
551
+ while i < len(lines):
552
+ line = lines[i]
553
+
554
+ # Check if this is our target section header
555
+ if i == section_line_idx:
556
+ # Add the section header and new content
557
+ result_lines.append(line)
558
+ result_lines.append(new_content)
559
+ i += 1
560
+
561
+ # Skip the original section content until next header or end
562
+ while i < len(lines):
563
+ next_line = lines[i]
564
+ # Stop consuming when we hit any header (preserve subsections)
565
+ if next_line.startswith("#"):
566
+ # We found another header - continue processing from here
567
+ break
568
+ i += 1
569
+ # Continue processing from the next header (don't increment i again)
570
+ continue
571
+
572
+ # Add all other lines (including subsequent sections)
573
+ result_lines.append(line)
574
+ i += 1
575
+
576
+ return "\n".join(result_lines)
577
+
578
+ def _prepend_after_frontmatter(self, current_content: str, content: str) -> str:
579
+ """Prepend content after frontmatter, preserving frontmatter structure."""
580
+
581
+ # Check if file has frontmatter
582
+ if has_frontmatter(current_content):
583
+ try:
584
+ # Parse and separate frontmatter from body
585
+ frontmatter_data = parse_frontmatter(current_content)
586
+ body_content = remove_frontmatter(current_content)
587
+
588
+ # Prepend content to the body
589
+ if content and not content.endswith("\n"):
590
+ new_body = content + "\n" + body_content
591
+ else:
592
+ new_body = content + body_content
593
+
594
+ # Reconstruct file with frontmatter + prepended body
595
+ yaml_fm = yaml.dump(frontmatter_data, sort_keys=False, allow_unicode=True)
596
+ return f"---\n{yaml_fm}---\n\n{new_body.strip()}"
597
+
598
+ except Exception as e: # pragma: no cover
599
+ logger.warning(
600
+ f"Failed to parse frontmatter during prepend: {e}"
601
+ ) # pragma: no cover
602
+ # Fall back to simple prepend if frontmatter parsing fails # pragma: no cover
603
+
604
+ # No frontmatter or parsing failed - do simple prepend # pragma: no cover
605
+ if content and not content.endswith("\n"): # pragma: no cover
606
+ return content + "\n" + current_content # pragma: no cover
607
+ return content + current_content # pragma: no cover
608
+
609
+ async def move_entity(
610
+ self,
611
+ identifier: str,
612
+ destination_path: str,
613
+ project_config: ProjectConfig,
614
+ app_config: BasicMemoryConfig,
615
+ ) -> EntityModel:
616
+ """Move entity to new location with database consistency.
617
+
618
+ Args:
619
+ identifier: Entity identifier (title, permalink, or memory:// URL)
620
+ destination_path: New path relative to project root
621
+ project_config: Project configuration for file operations
622
+ app_config: App configuration for permalink update settings
623
+
624
+ Returns:
625
+ Success message with move details
626
+
627
+ Raises:
628
+ EntityNotFoundError: If the entity cannot be found
629
+ ValueError: If move operation fails due to validation or filesystem errors
630
+ """
631
+ logger.debug(f"Moving entity: {identifier} to {destination_path}")
632
+
633
+ # 1. Resolve identifier to entity
634
+ entity = await self.link_resolver.resolve_link(identifier)
635
+ if not entity:
636
+ raise EntityNotFoundError(f"Entity not found: {identifier}")
637
+
638
+ current_path = entity.file_path
639
+ old_permalink = entity.permalink
640
+
641
+ # 2. Validate destination path format first
642
+ if not destination_path or destination_path.startswith("/") or not destination_path.strip():
643
+ raise ValueError(f"Invalid destination path: {destination_path}")
644
+
645
+ # 3. Validate paths
646
+ source_file = project_config.home / current_path
647
+ destination_file = project_config.home / destination_path
648
+
649
+ # Validate source exists
650
+ if not source_file.exists():
651
+ raise ValueError(f"Source file not found: {current_path}")
652
+
653
+ # Check if destination already exists
654
+ if destination_file.exists():
655
+ raise ValueError(f"Destination already exists: {destination_path}")
656
+
657
+ try:
658
+ # 4. Create destination directory if needed
659
+ destination_file.parent.mkdir(parents=True, exist_ok=True)
660
+
661
+ # 5. Move physical file
662
+ source_file.rename(destination_file)
663
+ logger.info(f"Moved file: {current_path} -> {destination_path}")
664
+
665
+ # 6. Prepare database updates
666
+ updates = {"file_path": destination_path}
667
+
668
+ # 7. Update permalink if configured
669
+ if app_config.update_permalinks_on_move:
670
+ # Generate new permalink from destination path
671
+ new_permalink = await self.resolve_permalink(destination_path)
672
+
673
+ # Update frontmatter with new permalink
674
+ await self.file_service.update_frontmatter(
675
+ destination_path, {"permalink": new_permalink}
676
+ )
677
+
678
+ updates["permalink"] = new_permalink
679
+ logger.info(f"Updated permalink: {old_permalink} -> {new_permalink}")
680
+
681
+ # 8. Recalculate checksum
682
+ new_checksum = await self.file_service.compute_checksum(destination_path)
683
+ updates["checksum"] = new_checksum
684
+
685
+ # 9. Update database
686
+ updated_entity = await self.repository.update(entity.id, updates)
687
+ if not updated_entity:
688
+ raise ValueError(f"Failed to update entity in database: {entity.id}")
689
+
690
+ return updated_entity
691
+
692
+ except Exception as e:
693
+ # Rollback: try to restore original file location if move succeeded
694
+ if destination_file.exists() and not source_file.exists():
695
+ try:
696
+ destination_file.rename(source_file)
697
+ logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
698
+ except Exception as rollback_error: # pragma: no cover
699
+ logger.error(f"Failed to rollback file move: {rollback_error}")
700
+
701
+ # Re-raise the original error with context
702
+ raise ValueError(f"Move failed: {str(e)}") from e
@@ -14,3 +14,9 @@ class EntityCreationError(Exception):
14
14
  """Raised when an entity cannot be created"""
15
15
 
16
16
  pass
17
+
18
+
19
+ class DirectoryOperationError(Exception):
20
+ """Raised when directory operations fail"""
21
+
22
+ pass
@@ -94,8 +94,8 @@ class FileService:
94
94
  """
95
95
  try:
96
96
  # Convert string to Path if needed
97
- path_obj = Path(path) if isinstance(path, str) else path
98
-
97
+ path_obj = self.base_path / path if isinstance(path, str) else path
98
+ logger.debug(f"Checking file existence: path={path_obj}")
99
99
  if path_obj.is_absolute():
100
100
  return path_obj.exists()
101
101
  else:
@@ -121,7 +121,7 @@ class FileService:
121
121
  FileOperationError: If write fails
122
122
  """
123
123
  # Convert string to Path if needed
124
- path_obj = Path(path) if isinstance(path, str) else path
124
+ path_obj = self.base_path / path if isinstance(path, str) else path
125
125
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
126
126
 
127
127
  try:
@@ -130,18 +130,17 @@ class FileService:
130
130
 
131
131
  # Write content atomically
132
132
  logger.info(
133
- "Writing file",
134
- operation="write_file",
135
- path=str(full_path),
136
- content_length=len(content),
137
- is_markdown=full_path.suffix.lower() == ".md",
133
+ "Writing file: "
134
+ f"path={path_obj}, "
135
+ f"content_length={len(content)}, "
136
+ f"is_markdown={full_path.suffix.lower() == '.md'}"
138
137
  )
139
138
 
140
139
  await file_utils.write_file_atomic(full_path, content)
141
140
 
142
141
  # Compute and return checksum
143
142
  checksum = await file_utils.compute_checksum(content)
144
- logger.debug("File write completed", path=str(full_path), checksum=checksum)
143
+ logger.debug(f"File write completed path={full_path}, {checksum=}")
145
144
  return checksum
146
145
 
147
146
  except Exception as e:
@@ -165,7 +164,7 @@ class FileService:
165
164
  FileOperationError: If read fails
166
165
  """
167
166
  # Convert string to Path if needed
168
- path_obj = Path(path) if isinstance(path, str) else path
167
+ path_obj = self.base_path / path if isinstance(path, str) else path
169
168
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
170
169
 
171
170
  try:
@@ -195,7 +194,7 @@ class FileService:
195
194
  path: Path to delete (Path or string)
196
195
  """
197
196
  # Convert string to Path if needed
198
- path_obj = Path(path) if isinstance(path, str) else path
197
+ path_obj = self.base_path / path if isinstance(path, str) else path
199
198
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
200
199
  full_path.unlink(missing_ok=True)
201
200
 
@@ -211,7 +210,7 @@ class FileService:
211
210
  Checksum of updated file
212
211
  """
213
212
  # Convert string to Path if needed
214
- path_obj = Path(path) if isinstance(path, str) else path
213
+ path_obj = self.base_path / path if isinstance(path, str) else path
215
214
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
216
215
  return await file_utils.update_frontmatter(full_path, updates)
217
216
 
@@ -228,7 +227,7 @@ class FileService:
228
227
  FileError: If checksum computation fails
229
228
  """
230
229
  # Convert string to Path if needed
231
- path_obj = Path(path) if isinstance(path, str) else path
230
+ path_obj = self.base_path / path if isinstance(path, str) else path
232
231
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
233
232
 
234
233
  try:
@@ -254,7 +253,7 @@ class FileService:
254
253
  File statistics
255
254
  """
256
255
  # Convert string to Path if needed
257
- path_obj = Path(path) if isinstance(path, str) else path
256
+ path_obj = self.base_path / path if isinstance(path, str) else path
258
257
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
259
258
  # get file timestamps
260
259
  return full_path.stat()
@@ -269,7 +268,7 @@ class FileService:
269
268
  MIME type of the file
270
269
  """
271
270
  # Convert string to Path if needed
272
- path_obj = Path(path) if isinstance(path, str) else path
271
+ path_obj = self.base_path / path if isinstance(path, str) else path
273
272
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
274
273
  # get file timestamps
275
274
  mime_type, _ = mimetypes.guess_type(full_path.name)