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,37 @@
1
+ class FileOperationError(Exception):
2
+ """Raised when file operations fail"""
3
+
4
+ pass
5
+
6
+
7
+ class EntityNotFoundError(Exception):
8
+ """Raised when an entity cannot be found"""
9
+
10
+ pass
11
+
12
+
13
+ class EntityCreationError(Exception):
14
+ """Raised when an entity cannot be created"""
15
+
16
+ pass
17
+
18
+
19
+ class DirectoryOperationError(Exception):
20
+ """Raised when directory operations fail"""
21
+
22
+ pass
23
+
24
+
25
+ class SyncFatalError(Exception):
26
+ """Raised when sync encounters a fatal error that prevents continuation.
27
+
28
+ Fatal errors include:
29
+ - Project deleted during sync (FOREIGN KEY constraint)
30
+ - Database corruption
31
+ - Critical system failures
32
+
33
+ When this exception is raised, the entire sync operation should be terminated
34
+ immediately rather than attempting to continue with remaining files.
35
+ """
36
+
37
+ pass
@@ -0,0 +1,541 @@
1
+ """Service for file operations with checksum tracking."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import mimetypes
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
9
+
10
+ import aiofiles
11
+
12
+ import yaml
13
+
14
+ from basic_memory import file_utils
15
+
16
+ if TYPE_CHECKING:
17
+ from basic_memory.config import BasicMemoryConfig
18
+ from basic_memory.file_utils import FileError, FileMetadata, ParseError
19
+ from basic_memory.markdown.markdown_processor import MarkdownProcessor
20
+ from basic_memory.models import Entity as EntityModel
21
+ from basic_memory.schemas import Entity as EntitySchema
22
+ from basic_memory.services.exceptions import FileOperationError
23
+ from basic_memory.utils import FilePath
24
+ from loguru import logger
25
+
26
+
27
+ class FileService:
28
+ """Service for handling file operations with concurrency control.
29
+
30
+ All paths are handled as Path objects internally. Strings are converted to
31
+ Path objects when passed in. Relative paths are assumed to be relative to
32
+ base_path.
33
+
34
+ Features:
35
+ - True async I/O with aiofiles (non-blocking)
36
+ - Built-in concurrency limits (semaphore)
37
+ - Consistent file writing with checksums
38
+ - Frontmatter management
39
+ - Atomic operations
40
+ - Error handling
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_path: Path,
46
+ markdown_processor: MarkdownProcessor,
47
+ max_concurrent_files: int = 10,
48
+ app_config: Optional["BasicMemoryConfig"] = None,
49
+ ):
50
+ self.base_path = base_path.resolve() # Get absolute path
51
+ self.markdown_processor = markdown_processor
52
+ self.app_config = app_config
53
+ # Semaphore to limit concurrent file operations
54
+ # Prevents OOM on large projects by processing files in batches
55
+ self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
56
+
57
+ def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
58
+ """Generate absolute filesystem path for entity.
59
+
60
+ Args:
61
+ entity: Entity model or schema with file_path attribute
62
+
63
+ Returns:
64
+ Absolute Path to the entity file
65
+ """
66
+ return self.base_path / entity.file_path
67
+
68
+ async def read_entity_content(self, entity: EntityModel) -> str:
69
+ """Get entity's content without frontmatter or structured sections.
70
+
71
+ Used to index for search. Returns raw content without frontmatter,
72
+ observations, or relations.
73
+
74
+ Args:
75
+ entity: Entity to read content for
76
+
77
+ Returns:
78
+ Raw content string without metadata sections
79
+ """
80
+ logger.debug(f"Reading entity content, entity_id={entity.id}, permalink={entity.permalink}")
81
+
82
+ file_path = self.get_entity_path(entity)
83
+ markdown = await self.markdown_processor.read_file(file_path)
84
+ return markdown.content or ""
85
+
86
+ async def delete_entity_file(self, entity: EntityModel) -> None:
87
+ """Delete entity file from filesystem.
88
+
89
+ Args:
90
+ entity: Entity model whose file should be deleted
91
+
92
+ Raises:
93
+ FileOperationError: If deletion fails
94
+ """
95
+ path = self.get_entity_path(entity)
96
+ await self.delete_file(path)
97
+
98
+ async def exists(self, path: FilePath) -> bool:
99
+ """Check if file exists at the provided path.
100
+
101
+ If path is relative, it is assumed to be relative to base_path.
102
+
103
+ Args:
104
+ path: Path to check (Path or string)
105
+
106
+ Returns:
107
+ True if file exists, False otherwise
108
+
109
+ Raises:
110
+ FileOperationError: If check fails
111
+ """
112
+ try:
113
+ # Convert string to Path if needed
114
+ path_obj = self.base_path / path if isinstance(path, str) else path
115
+ logger.debug(f"Checking file existence: path={path_obj}")
116
+ if path_obj.is_absolute():
117
+ return path_obj.exists()
118
+ else:
119
+ return (self.base_path / path_obj).exists()
120
+ except Exception as e:
121
+ logger.error("Failed to check file existence", path=str(path), error=str(e))
122
+ raise FileOperationError(f"Failed to check file existence: {e}")
123
+
124
+ async def ensure_directory(self, path: FilePath) -> None:
125
+ """Ensure directory exists, creating if necessary.
126
+
127
+ Uses semaphore to control concurrency for directory creation operations.
128
+
129
+ Args:
130
+ path: Directory path to ensure (Path or string)
131
+
132
+ Raises:
133
+ FileOperationError: If directory creation fails
134
+ """
135
+ try:
136
+ # Convert string to Path if needed
137
+ path_obj = self.base_path / path if isinstance(path, str) else path
138
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
139
+
140
+ # Use semaphore for concurrency control
141
+ async with self._file_semaphore:
142
+ # Run blocking mkdir in thread pool
143
+ loop = asyncio.get_event_loop()
144
+ await loop.run_in_executor(
145
+ None, lambda: full_path.mkdir(parents=True, exist_ok=True)
146
+ )
147
+ except Exception as e: # pragma: no cover
148
+ logger.error("Failed to create directory", path=str(path), error=str(e))
149
+ raise FileOperationError(f"Failed to create directory {path}: {e}")
150
+
151
+ async def write_file(self, path: FilePath, content: str) -> str:
152
+ """Write content to file and return checksum.
153
+
154
+ Handles both absolute and relative paths. Relative paths are resolved
155
+ against base_path.
156
+
157
+ If format_on_save is enabled in config, runs the configured formatter
158
+ after writing and returns the checksum of the formatted content.
159
+
160
+ Args:
161
+ path: Where to write (Path or string)
162
+ content: Content to write
163
+
164
+ Returns:
165
+ Checksum of written content (or formatted content if formatting enabled)
166
+
167
+ Raises:
168
+ FileOperationError: If write fails
169
+ """
170
+ # Convert string to Path if needed
171
+ path_obj = self.base_path / path if isinstance(path, str) else path
172
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
173
+
174
+ try:
175
+ # Ensure parent directory exists
176
+ await self.ensure_directory(full_path.parent)
177
+
178
+ # Write content atomically
179
+ logger.info(
180
+ "Writing file: "
181
+ f"path={path_obj}, "
182
+ f"content_length={len(content)}, "
183
+ f"is_markdown={full_path.suffix.lower() == '.md'}"
184
+ )
185
+
186
+ await file_utils.write_file_atomic(full_path, content)
187
+
188
+ # Format file if configured
189
+ final_content = content
190
+ if self.app_config:
191
+ formatted_content = await file_utils.format_file(
192
+ full_path, self.app_config, is_markdown=self.is_markdown(path)
193
+ )
194
+ if formatted_content is not None:
195
+ final_content = formatted_content
196
+
197
+ # Compute and return checksum of final content
198
+ checksum = await file_utils.compute_checksum(final_content)
199
+ logger.debug(f"File write completed path={full_path}, {checksum=}")
200
+ return checksum
201
+
202
+ except Exception as e:
203
+ logger.exception("File write error", path=str(full_path), error=str(e))
204
+ raise FileOperationError(f"Failed to write file: {e}")
205
+
206
+ async def read_file_content(self, path: FilePath) -> str:
207
+ """Read file content using true async I/O with aiofiles.
208
+
209
+ Handles both absolute and relative paths. Relative paths are resolved
210
+ against base_path.
211
+
212
+ Args:
213
+ path: Path to read (Path or string)
214
+
215
+ Returns:
216
+ File content as string
217
+
218
+ Raises:
219
+ FileOperationError: If read fails
220
+ """
221
+ # Convert string to Path if needed
222
+ path_obj = self.base_path / path if isinstance(path, str) else path
223
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
224
+
225
+ try:
226
+ logger.debug("Reading file content", operation="read_file_content", path=str(full_path))
227
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
228
+ content = await f.read()
229
+
230
+ logger.debug(
231
+ "File read completed",
232
+ path=str(full_path),
233
+ content_length=len(content),
234
+ )
235
+ return content
236
+
237
+ except Exception as e:
238
+ logger.exception("File read error", path=str(full_path), error=str(e))
239
+ raise FileOperationError(f"Failed to read file: {e}")
240
+
241
+ async def read_file_bytes(self, path: FilePath) -> bytes:
242
+ """Read file content as bytes using true async I/O with aiofiles.
243
+
244
+ This method reads files in binary mode, suitable for non-text files
245
+ like images, PDFs, etc. For cloud compatibility with S3FileService.
246
+
247
+ Args:
248
+ path: Path to read (Path or string)
249
+
250
+ Returns:
251
+ File content as bytes
252
+
253
+ Raises:
254
+ FileOperationError: If read fails
255
+ """
256
+ # Convert string to Path if needed
257
+ path_obj = self.base_path / path if isinstance(path, str) else path
258
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
259
+
260
+ try:
261
+ logger.debug("Reading file bytes", operation="read_file_bytes", path=str(full_path))
262
+ async with aiofiles.open(full_path, mode="rb") as f:
263
+ content = await f.read()
264
+
265
+ logger.debug(
266
+ "File read completed",
267
+ path=str(full_path),
268
+ content_length=len(content),
269
+ )
270
+ return content
271
+
272
+ except Exception as e:
273
+ logger.exception("File read error", path=str(full_path), error=str(e))
274
+ raise FileOperationError(f"Failed to read file: {e}")
275
+
276
+ async def read_file(self, path: FilePath) -> Tuple[str, str]:
277
+ """Read file and compute checksum using true async I/O.
278
+
279
+ Uses aiofiles for non-blocking file reads.
280
+
281
+ Handles both absolute and relative paths. Relative paths are resolved
282
+ against base_path.
283
+
284
+ Args:
285
+ path: Path to read (Path or string)
286
+
287
+ Returns:
288
+ Tuple of (content, checksum)
289
+
290
+ Raises:
291
+ FileOperationError: If read fails
292
+ """
293
+ # Convert string to Path if needed
294
+ path_obj = self.base_path / path if isinstance(path, str) else path
295
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
296
+
297
+ try:
298
+ logger.debug("Reading file", operation="read_file", path=str(full_path))
299
+
300
+ # Use aiofiles for non-blocking read
301
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
302
+ content = await f.read()
303
+
304
+ checksum = await file_utils.compute_checksum(content)
305
+
306
+ logger.debug(
307
+ "File read completed",
308
+ path=str(full_path),
309
+ checksum=checksum,
310
+ content_length=len(content),
311
+ )
312
+ return content, checksum
313
+
314
+ except Exception as e:
315
+ logger.exception("File read error", path=str(full_path), error=str(e))
316
+ raise FileOperationError(f"Failed to read file: {e}")
317
+
318
+ async def delete_file(self, path: FilePath) -> None:
319
+ """Delete file if it exists.
320
+
321
+ Handles both absolute and relative paths. Relative paths are resolved
322
+ against base_path.
323
+
324
+ Args:
325
+ path: Path to delete (Path or string)
326
+ """
327
+ # Convert string to Path if needed
328
+ path_obj = self.base_path / path if isinstance(path, str) else path
329
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
330
+ full_path.unlink(missing_ok=True)
331
+
332
+ async def move_file(self, source: FilePath, destination: FilePath) -> None:
333
+ """Move/rename a file from source to destination.
334
+
335
+ This method abstracts the underlying storage (filesystem vs cloud).
336
+ Default implementation uses atomic filesystem rename, but cloud-backed
337
+ implementations (e.g., S3) can override to copy+delete.
338
+
339
+ Args:
340
+ source: Source path (relative to base_path or absolute)
341
+ destination: Destination path (relative to base_path or absolute)
342
+
343
+ Raises:
344
+ FileOperationError: If the move fails
345
+ """
346
+ # Convert strings to Paths and resolve relative paths against base_path
347
+ src_obj = self.base_path / source if isinstance(source, str) else source
348
+ dst_obj = self.base_path / destination if isinstance(destination, str) else destination
349
+ src_full = src_obj if src_obj.is_absolute() else self.base_path / src_obj
350
+ dst_full = dst_obj if dst_obj.is_absolute() else self.base_path / dst_obj
351
+
352
+ try:
353
+ # Ensure destination directory exists
354
+ await self.ensure_directory(dst_full.parent)
355
+
356
+ # Use semaphore for concurrency control and run blocking rename in executor
357
+ async with self._file_semaphore:
358
+ loop = asyncio.get_event_loop()
359
+ await loop.run_in_executor(None, lambda: src_full.rename(dst_full))
360
+ except Exception as e:
361
+ logger.exception(
362
+ "File move error",
363
+ source=str(src_full),
364
+ destination=str(dst_full),
365
+ error=str(e),
366
+ )
367
+ raise FileOperationError(f"Failed to move file {source} -> {destination}: {e}")
368
+
369
+ async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
370
+ """Update frontmatter fields in a file while preserving all content.
371
+
372
+ Only modifies the frontmatter section, leaving all content untouched.
373
+ Creates frontmatter section if none exists.
374
+ Returns checksum of updated file.
375
+
376
+ Uses aiofiles for true async I/O (non-blocking).
377
+
378
+ Args:
379
+ path: Path to markdown file (Path or string)
380
+ updates: Dict of frontmatter fields to update
381
+
382
+ Returns:
383
+ Checksum of updated file
384
+
385
+ Raises:
386
+ FileOperationError: If file operations fail
387
+ ParseError: If frontmatter parsing fails
388
+ """
389
+ # Convert string to Path if needed
390
+ path_obj = self.base_path / path if isinstance(path, str) else path
391
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
392
+
393
+ try:
394
+ # Read current content using aiofiles
395
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
396
+ content = await f.read()
397
+
398
+ # Parse current frontmatter with proper error handling for malformed YAML
399
+ current_fm = {}
400
+ if file_utils.has_frontmatter(content):
401
+ try:
402
+ current_fm = file_utils.parse_frontmatter(content)
403
+ content = file_utils.remove_frontmatter(content)
404
+ except (ParseError, yaml.YAMLError) as e:
405
+ # Log warning and treat as plain markdown without frontmatter
406
+ logger.warning(
407
+ f"Failed to parse YAML frontmatter in {full_path}: {e}. "
408
+ "Treating file as plain markdown without frontmatter."
409
+ )
410
+ # Keep full content, treat as having no frontmatter
411
+ current_fm = {}
412
+
413
+ # Update frontmatter
414
+ new_fm = {**current_fm, **updates}
415
+
416
+ # Write new file with updated frontmatter
417
+ yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
418
+ final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
419
+
420
+ logger.debug(
421
+ "Updating frontmatter", path=str(full_path), update_keys=list(updates.keys())
422
+ )
423
+
424
+ await file_utils.write_file_atomic(full_path, final_content)
425
+
426
+ # Format file if configured
427
+ content_for_checksum = final_content
428
+ if self.app_config:
429
+ formatted_content = await file_utils.format_file(
430
+ full_path, self.app_config, is_markdown=self.is_markdown(path)
431
+ )
432
+ if formatted_content is not None:
433
+ content_for_checksum = formatted_content
434
+
435
+ return await file_utils.compute_checksum(content_for_checksum)
436
+
437
+ except Exception as e:
438
+ # Only log real errors (not YAML parsing, which is handled above)
439
+ if not isinstance(e, (ParseError, yaml.YAMLError)):
440
+ logger.error(
441
+ "Failed to update frontmatter",
442
+ path=str(full_path),
443
+ error=str(e),
444
+ )
445
+ raise FileOperationError(f"Failed to update frontmatter: {e}")
446
+
447
+ async def compute_checksum(self, path: FilePath) -> str:
448
+ """Compute checksum for a file using true async I/O.
449
+
450
+ Uses aiofiles for non-blocking I/O with 64KB chunked reading.
451
+ Semaphore limits concurrent file operations to prevent OOM.
452
+ Memory usage is constant regardless of file size.
453
+
454
+ Args:
455
+ path: Path to the file (Path or string)
456
+
457
+ Returns:
458
+ SHA256 checksum hex string
459
+
460
+ Raises:
461
+ FileError: If checksum computation fails
462
+ """
463
+ # Convert string to Path if needed
464
+ path_obj = self.base_path / path if isinstance(path, str) else path
465
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
466
+
467
+ # Semaphore controls concurrency - max N files processed at once
468
+ async with self._file_semaphore:
469
+ try:
470
+ hasher = hashlib.sha256()
471
+ chunk_size = 65536 # 64KB chunks
472
+
473
+ # async I/O with aiofiles
474
+ async with aiofiles.open(full_path, mode="rb") as f:
475
+ while chunk := await f.read(chunk_size):
476
+ hasher.update(chunk)
477
+
478
+ return hasher.hexdigest()
479
+
480
+ except Exception as e: # pragma: no cover
481
+ logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
482
+ raise FileError(f"Failed to compute checksum for {path}: {e}")
483
+
484
+ async def get_file_metadata(self, path: FilePath) -> FileMetadata:
485
+ """Return file metadata for a given path.
486
+
487
+ This method is async to support cloud implementations (S3FileService)
488
+ where file metadata requires async operations (head_object).
489
+
490
+ Args:
491
+ path: Path to the file (Path or string)
492
+
493
+ Returns:
494
+ FileMetadata with size, created_at, and modified_at
495
+ """
496
+ # Convert string to Path if needed
497
+ path_obj = self.base_path / path if isinstance(path, str) else path
498
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
499
+
500
+ # Run blocking stat() in thread pool to maintain async compatibility
501
+ loop = asyncio.get_event_loop()
502
+ stat_result = await loop.run_in_executor(None, full_path.stat)
503
+
504
+ return FileMetadata(
505
+ size=stat_result.st_size,
506
+ created_at=datetime.fromtimestamp(stat_result.st_ctime).astimezone(),
507
+ modified_at=datetime.fromtimestamp(stat_result.st_mtime).astimezone(),
508
+ )
509
+
510
+ def content_type(self, path: FilePath) -> str:
511
+ """Return content_type for a given path.
512
+
513
+ Args:
514
+ path: Path to the file (Path or string)
515
+
516
+ Returns:
517
+ MIME type of the file
518
+ """
519
+ # Convert string to Path if needed
520
+ path_obj = self.base_path / path if isinstance(path, str) else path
521
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
522
+ # get file timestamps
523
+ mime_type, _ = mimetypes.guess_type(full_path.name)
524
+
525
+ # .canvas files are json
526
+ if full_path.suffix == ".canvas":
527
+ mime_type = "application/json"
528
+
529
+ content_type = mime_type or "text/plain"
530
+ return content_type
531
+
532
+ def is_markdown(self, path: FilePath) -> bool:
533
+ """Check if a file is a markdown file.
534
+
535
+ Args:
536
+ path: Path to the file (Path or string)
537
+
538
+ Returns:
539
+ True if the file is a markdown file, False otherwise
540
+ """
541
+ return self.content_type(path) == "text/markdown"