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
@@ -14,3 +14,24 @@ 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
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
@@ -1,25 +1,35 @@
1
1
  """Service for file operations with checksum tracking."""
2
2
 
3
+ import asyncio
4
+ import hashlib
5
+ import mimetypes
6
+ from os import stat_result
3
7
  from pathlib import Path
4
- from typing import Tuple, Union
8
+ from typing import Any, Dict, Tuple, Union
5
9
 
6
- from loguru import logger
10
+ import aiofiles
11
+ import yaml
7
12
 
8
13
  from basic_memory import file_utils
14
+ from basic_memory.file_utils import FileError, ParseError
9
15
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
10
16
  from basic_memory.models import Entity as EntityModel
11
17
  from basic_memory.schemas import Entity as EntitySchema
12
18
  from basic_memory.services.exceptions import FileOperationError
19
+ from basic_memory.utils import FilePath
20
+ from loguru import logger
13
21
 
14
22
 
15
23
  class FileService:
16
- """Service for handling file operations.
24
+ """Service for handling file operations with concurrency control.
17
25
 
18
26
  All paths are handled as Path objects internally. Strings are converted to
19
27
  Path objects when passed in. Relative paths are assumed to be relative to
20
28
  base_path.
21
29
 
22
30
  Features:
31
+ - True async I/O with aiofiles (non-blocking)
32
+ - Built-in concurrency limits (semaphore)
23
33
  - Consistent file writing with checksums
24
34
  - Frontmatter management
25
35
  - Atomic operations
@@ -30,9 +40,13 @@ class FileService:
30
40
  self,
31
41
  base_path: Path,
32
42
  markdown_processor: MarkdownProcessor,
43
+ max_concurrent_files: int = 10,
33
44
  ):
34
45
  self.base_path = base_path.resolve() # Get absolute path
35
46
  self.markdown_processor = markdown_processor
47
+ # Semaphore to limit concurrent file operations
48
+ # Prevents OOM on large projects by processing files in batches
49
+ self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
36
50
 
37
51
  def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
38
52
  """Generate absolute filesystem path for entity.
@@ -57,7 +71,7 @@ class FileService:
57
71
  Returns:
58
72
  Raw content string without metadata sections
59
73
  """
60
- logger.debug(f"Reading entity with permalink: {entity.permalink}")
74
+ logger.debug(f"Reading entity content, entity_id={entity.id}, permalink={entity.permalink}")
61
75
 
62
76
  file_path = self.get_entity_path(entity)
63
77
  markdown = await self.markdown_processor.read_file(file_path)
@@ -75,13 +89,13 @@ class FileService:
75
89
  path = self.get_entity_path(entity)
76
90
  await self.delete_file(path)
77
91
 
78
- async def exists(self, path: Union[Path, str]) -> bool:
92
+ async def exists(self, path: FilePath) -> bool:
79
93
  """Check if file exists at the provided path.
80
94
 
81
95
  If path is relative, it is assumed to be relative to base_path.
82
96
 
83
97
  Args:
84
- path: Path to check (Path object or string)
98
+ path: Path to check (Path or string)
85
99
 
86
100
  Returns:
87
101
  True if file exists, False otherwise
@@ -90,23 +104,52 @@ class FileService:
90
104
  FileOperationError: If check fails
91
105
  """
92
106
  try:
93
- path = Path(path)
94
- if path.is_absolute():
95
- return path.exists()
107
+ # Convert string to Path if needed
108
+ path_obj = self.base_path / path if isinstance(path, str) else path
109
+ logger.debug(f"Checking file existence: path={path_obj}")
110
+ if path_obj.is_absolute():
111
+ return path_obj.exists()
96
112
  else:
97
- return (self.base_path / path).exists()
113
+ return (self.base_path / path_obj).exists()
98
114
  except Exception as e:
99
- logger.error(f"Failed to check file existence {path}: {e}")
115
+ logger.error("Failed to check file existence", path=str(path), error=str(e))
100
116
  raise FileOperationError(f"Failed to check file existence: {e}")
101
117
 
102
- async def write_file(self, path: Union[Path, str], content: str) -> str:
118
+ async def ensure_directory(self, path: FilePath) -> None:
119
+ """Ensure directory exists, creating if necessary.
120
+
121
+ Uses semaphore to control concurrency for directory creation operations.
122
+
123
+ Args:
124
+ path: Directory path to ensure (Path or string)
125
+
126
+ Raises:
127
+ FileOperationError: If directory creation fails
128
+ """
129
+ try:
130
+ # Convert string to Path if needed
131
+ path_obj = self.base_path / path if isinstance(path, str) else path
132
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
133
+
134
+ # Use semaphore for concurrency control
135
+ async with self._file_semaphore:
136
+ # Run blocking mkdir in thread pool
137
+ loop = asyncio.get_event_loop()
138
+ await loop.run_in_executor(
139
+ None, lambda: full_path.mkdir(parents=True, exist_ok=True)
140
+ )
141
+ except Exception as e: # pragma: no cover
142
+ logger.error("Failed to create directory", path=str(path), error=str(e))
143
+ raise FileOperationError(f"Failed to create directory {path}: {e}")
144
+
145
+ async def write_file(self, path: FilePath, content: str) -> str:
103
146
  """Write content to file and return checksum.
104
147
 
105
148
  Handles both absolute and relative paths. Relative paths are resolved
106
149
  against base_path.
107
150
 
108
151
  Args:
109
- path: Where to write (Path object or string)
152
+ path: Where to write (Path or string)
110
153
  content: Content to write
111
154
 
112
155
  Returns:
@@ -115,33 +158,78 @@ class FileService:
115
158
  Raises:
116
159
  FileOperationError: If write fails
117
160
  """
118
- path = Path(path)
119
- full_path = path if path.is_absolute() else self.base_path / path
161
+ # Convert string to Path if needed
162
+ path_obj = self.base_path / path if isinstance(path, str) else path
163
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
120
164
 
121
165
  try:
122
166
  # Ensure parent directory exists
123
- await file_utils.ensure_directory(full_path.parent)
167
+ await self.ensure_directory(full_path.parent)
124
168
 
125
169
  # Write content atomically
170
+ logger.info(
171
+ "Writing file: "
172
+ f"path={path_obj}, "
173
+ f"content_length={len(content)}, "
174
+ f"is_markdown={full_path.suffix.lower() == '.md'}"
175
+ )
176
+
126
177
  await file_utils.write_file_atomic(full_path, content)
127
178
 
128
179
  # Compute and return checksum
129
180
  checksum = await file_utils.compute_checksum(content)
130
- logger.debug(f"wrote file: {full_path}, checksum: {checksum}")
181
+ logger.debug(f"File write completed path={full_path}, {checksum=}")
131
182
  return checksum
132
183
 
133
184
  except Exception as e:
134
- logger.error(f"Failed to write file {full_path}: {e}")
185
+ logger.exception("File write error", path=str(full_path), error=str(e))
135
186
  raise FileOperationError(f"Failed to write file: {e}")
136
187
 
137
- async def read_file(self, path: Union[Path, str]) -> Tuple[str, str]:
138
- """Read file and compute checksum.
188
+ async def read_file_content(self, path: FilePath) -> str:
189
+ """Read file content using true async I/O with aiofiles.
139
190
 
140
191
  Handles both absolute and relative paths. Relative paths are resolved
141
192
  against base_path.
142
193
 
143
194
  Args:
144
- path: Path to read (Path object or string)
195
+ path: Path to read (Path or string)
196
+
197
+ Returns:
198
+ File content as string
199
+
200
+ Raises:
201
+ FileOperationError: If read fails
202
+ """
203
+ # Convert string to Path if needed
204
+ path_obj = self.base_path / path if isinstance(path, str) else path
205
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
206
+
207
+ try:
208
+ logger.debug("Reading file content", operation="read_file_content", path=str(full_path))
209
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
210
+ content = await f.read()
211
+
212
+ logger.debug(
213
+ "File read completed",
214
+ path=str(full_path),
215
+ content_length=len(content),
216
+ )
217
+ return content
218
+
219
+ except Exception as e:
220
+ logger.exception("File read error", path=str(full_path), error=str(e))
221
+ raise FileOperationError(f"Failed to read file: {e}")
222
+
223
+ async def read_file(self, path: FilePath) -> Tuple[str, str]:
224
+ """Read file and compute checksum using true async I/O.
225
+
226
+ Uses aiofiles for non-blocking file reads.
227
+
228
+ Handles both absolute and relative paths. Relative paths are resolved
229
+ against base_path.
230
+
231
+ Args:
232
+ path: Path to read (Path or string)
145
233
 
146
234
  Returns:
147
235
  Tuple of (content, checksum)
@@ -149,28 +237,194 @@ class FileService:
149
237
  Raises:
150
238
  FileOperationError: If read fails
151
239
  """
152
- path = Path(path)
153
- full_path = path if path.is_absolute() else self.base_path / path
240
+ # Convert string to Path if needed
241
+ path_obj = self.base_path / path if isinstance(path, str) else path
242
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
154
243
 
155
244
  try:
156
- content = path.read_text()
245
+ logger.debug("Reading file", operation="read_file", path=str(full_path))
246
+
247
+ # Use aiofiles for non-blocking read
248
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
249
+ content = await f.read()
250
+
157
251
  checksum = await file_utils.compute_checksum(content)
158
- logger.debug(f"read file: {full_path}, checksum: {checksum}")
252
+
253
+ logger.debug(
254
+ "File read completed",
255
+ path=str(full_path),
256
+ checksum=checksum,
257
+ content_length=len(content),
258
+ )
159
259
  return content, checksum
160
260
 
161
261
  except Exception as e:
162
- logger.error(f"Failed to read file {full_path}: {e}")
262
+ logger.exception("File read error", path=str(full_path), error=str(e))
163
263
  raise FileOperationError(f"Failed to read file: {e}")
164
264
 
165
- async def delete_file(self, path: Union[Path, str]) -> None:
265
+ async def delete_file(self, path: FilePath) -> None:
166
266
  """Delete file if it exists.
167
267
 
168
268
  Handles both absolute and relative paths. Relative paths are resolved
169
269
  against base_path.
170
270
 
171
271
  Args:
172
- path: Path to delete (Path object or string)
272
+ path: Path to delete (Path or string)
173
273
  """
174
- path = Path(path)
175
- full_path = path if path.is_absolute() else self.base_path / path
274
+ # Convert string to Path if needed
275
+ path_obj = self.base_path / path if isinstance(path, str) else path
276
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
176
277
  full_path.unlink(missing_ok=True)
278
+
279
+ async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
280
+ """Update frontmatter fields in a file while preserving all content.
281
+
282
+ Only modifies the frontmatter section, leaving all content untouched.
283
+ Creates frontmatter section if none exists.
284
+ Returns checksum of updated file.
285
+
286
+ Uses aiofiles for true async I/O (non-blocking).
287
+
288
+ Args:
289
+ path: Path to markdown file (Path or string)
290
+ updates: Dict of frontmatter fields to update
291
+
292
+ Returns:
293
+ Checksum of updated file
294
+
295
+ Raises:
296
+ FileOperationError: If file operations fail
297
+ ParseError: If frontmatter parsing fails
298
+ """
299
+ # Convert string to Path if needed
300
+ path_obj = self.base_path / path if isinstance(path, str) else path
301
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
302
+
303
+ try:
304
+ # Read current content using aiofiles
305
+ async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
306
+ content = await f.read()
307
+
308
+ # Parse current frontmatter with proper error handling for malformed YAML
309
+ current_fm = {}
310
+ if file_utils.has_frontmatter(content):
311
+ try:
312
+ current_fm = file_utils.parse_frontmatter(content)
313
+ content = file_utils.remove_frontmatter(content)
314
+ except (ParseError, yaml.YAMLError) as e:
315
+ # Log warning and treat as plain markdown without frontmatter
316
+ logger.warning(
317
+ f"Failed to parse YAML frontmatter in {full_path}: {e}. "
318
+ "Treating file as plain markdown without frontmatter."
319
+ )
320
+ # Keep full content, treat as having no frontmatter
321
+ current_fm = {}
322
+
323
+ # Update frontmatter
324
+ new_fm = {**current_fm, **updates}
325
+
326
+ # Write new file with updated frontmatter
327
+ yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
328
+ final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
329
+
330
+ logger.debug(
331
+ "Updating frontmatter", path=str(full_path), update_keys=list(updates.keys())
332
+ )
333
+
334
+ await file_utils.write_file_atomic(full_path, final_content)
335
+ return await file_utils.compute_checksum(final_content)
336
+
337
+ except Exception as e:
338
+ # Only log real errors (not YAML parsing, which is handled above)
339
+ if not isinstance(e, (ParseError, yaml.YAMLError)):
340
+ logger.error(
341
+ "Failed to update frontmatter",
342
+ path=str(full_path),
343
+ error=str(e),
344
+ )
345
+ raise FileOperationError(f"Failed to update frontmatter: {e}")
346
+
347
+ async def compute_checksum(self, path: FilePath) -> str:
348
+ """Compute checksum for a file using true async I/O.
349
+
350
+ Uses aiofiles for non-blocking I/O with 64KB chunked reading.
351
+ Semaphore limits concurrent file operations to prevent OOM.
352
+ Memory usage is constant regardless of file size.
353
+
354
+ Args:
355
+ path: Path to the file (Path or string)
356
+
357
+ Returns:
358
+ SHA256 checksum hex string
359
+
360
+ Raises:
361
+ FileError: If checksum computation fails
362
+ """
363
+ # Convert string to Path if needed
364
+ path_obj = self.base_path / path if isinstance(path, str) else path
365
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
366
+
367
+ # Semaphore controls concurrency - max N files processed at once
368
+ async with self._file_semaphore:
369
+ try:
370
+ hasher = hashlib.sha256()
371
+ chunk_size = 65536 # 64KB chunks
372
+
373
+ # async I/O with aiofiles
374
+ async with aiofiles.open(full_path, mode="rb") as f:
375
+ while chunk := await f.read(chunk_size):
376
+ hasher.update(chunk)
377
+
378
+ return hasher.hexdigest()
379
+
380
+ except Exception as e: # pragma: no cover
381
+ logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
382
+ raise FileError(f"Failed to compute checksum for {path}: {e}")
383
+
384
+ def file_stats(self, path: FilePath) -> stat_result:
385
+ """Return file stats for a given path.
386
+
387
+ Args:
388
+ path: Path to the file (Path or string)
389
+
390
+ Returns:
391
+ File statistics
392
+ """
393
+ # Convert string to Path if needed
394
+ path_obj = self.base_path / path if isinstance(path, str) else path
395
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
396
+ # get file timestamps
397
+ return full_path.stat()
398
+
399
+ def content_type(self, path: FilePath) -> str:
400
+ """Return content_type for a given path.
401
+
402
+ Args:
403
+ path: Path to the file (Path or string)
404
+
405
+ Returns:
406
+ MIME type of the file
407
+ """
408
+ # Convert string to Path if needed
409
+ path_obj = self.base_path / path if isinstance(path, str) else path
410
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
411
+ # get file timestamps
412
+ mime_type, _ = mimetypes.guess_type(full_path.name)
413
+
414
+ # .canvas files are json
415
+ if full_path.suffix == ".canvas":
416
+ mime_type = "application/json"
417
+
418
+ content_type = mime_type or "text/plain"
419
+ return content_type
420
+
421
+ def is_markdown(self, path: FilePath) -> bool:
422
+ """Check if a file is a markdown file.
423
+
424
+ Args:
425
+ path: Path to the file (Path or string)
426
+
427
+ Returns:
428
+ True if the file is a markdown file, False otherwise
429
+ """
430
+ return self.content_type(path) == "text/markdown"
@@ -0,0 +1,191 @@
1
+ """Shared initialization service for Basic Memory.
2
+
3
+ This module provides shared initialization functions used by both CLI and API
4
+ to ensure consistent application startup across all entry points.
5
+ """
6
+
7
+ import asyncio
8
+ from pathlib import Path
9
+
10
+ from loguru import logger
11
+
12
+ from basic_memory import db
13
+ from basic_memory.config import BasicMemoryConfig
14
+ from basic_memory.models import Project
15
+ from basic_memory.repository import (
16
+ ProjectRepository,
17
+ )
18
+
19
+
20
+ async def initialize_database(app_config: BasicMemoryConfig) -> None:
21
+ """Initialize database with migrations handled automatically by get_or_create_db.
22
+
23
+ Args:
24
+ app_config: The Basic Memory project configuration
25
+
26
+ Note:
27
+ Database migrations are now handled automatically when the database
28
+ connection is first established via get_or_create_db().
29
+ """
30
+ # Trigger database initialization and migrations by getting the database connection
31
+ try:
32
+ await db.get_or_create_db(app_config.database_path)
33
+ logger.info("Database initialization completed")
34
+ except Exception as e:
35
+ logger.error(f"Error initializing database: {e}")
36
+ # Allow application to continue - it might still work
37
+ # depending on what the error was, and will fail with a
38
+ # more specific error if the database is actually unusable
39
+
40
+
41
+ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
42
+ """Ensure all projects in config.json exist in the projects table and vice versa.
43
+
44
+ This uses the ProjectService's synchronize_projects method to ensure bidirectional
45
+ synchronization between the configuration file and the database.
46
+
47
+ Args:
48
+ app_config: The Basic Memory application configuration
49
+ """
50
+ logger.info("Reconciling projects from config with database...")
51
+
52
+ # Get database session - migrations handled centrally
53
+ _, session_maker = await db.get_or_create_db(
54
+ db_path=app_config.database_path,
55
+ db_type=db.DatabaseType.FILESYSTEM,
56
+ ensure_migrations=False,
57
+ )
58
+ project_repository = ProjectRepository(session_maker)
59
+
60
+ # Import ProjectService here to avoid circular imports
61
+ from basic_memory.services.project_service import ProjectService
62
+
63
+ try:
64
+ # Create project service and synchronize projects
65
+ project_service = ProjectService(repository=project_repository)
66
+ await project_service.synchronize_projects()
67
+ logger.info("Projects successfully reconciled between config and database")
68
+ except Exception as e:
69
+ # Log the error but continue with initialization
70
+ logger.error(f"Error during project synchronization: {e}")
71
+ logger.info("Continuing with initialization despite synchronization error")
72
+
73
+
74
+ async def initialize_file_sync(
75
+ app_config: BasicMemoryConfig,
76
+ ):
77
+ """Initialize file synchronization services. This function starts the watch service and does not return
78
+
79
+ Args:
80
+ app_config: The Basic Memory project configuration
81
+
82
+ Returns:
83
+ The watch service task that's monitoring file changes
84
+ """
85
+
86
+ # delay import
87
+ from basic_memory.sync import WatchService
88
+
89
+ # Load app configuration - migrations handled centrally
90
+ _, session_maker = await db.get_or_create_db(
91
+ db_path=app_config.database_path,
92
+ db_type=db.DatabaseType.FILESYSTEM,
93
+ ensure_migrations=False,
94
+ )
95
+ project_repository = ProjectRepository(session_maker)
96
+
97
+ # Initialize watch service
98
+ watch_service = WatchService(
99
+ app_config=app_config,
100
+ project_repository=project_repository,
101
+ quiet=True,
102
+ )
103
+
104
+ # Get active projects
105
+ active_projects = await project_repository.get_active_projects()
106
+
107
+ # Start sync for all projects as background tasks (non-blocking)
108
+ async def sync_project_background(project: Project):
109
+ """Sync a single project in the background."""
110
+ # avoid circular imports
111
+ from basic_memory.sync.sync_service import get_sync_service
112
+
113
+ logger.info(f"Starting background sync for project: {project.name}")
114
+ try:
115
+ # Create sync service
116
+ sync_service = await get_sync_service(project)
117
+
118
+ sync_dir = Path(project.path)
119
+ await sync_service.sync(sync_dir, project_name=project.name)
120
+ logger.info(f"Background sync completed successfully for project: {project.name}")
121
+ except Exception as e: # pragma: no cover
122
+ logger.error(f"Error in background sync for project {project.name}: {e}")
123
+
124
+ # Create background tasks for all project syncs (non-blocking)
125
+ sync_tasks = [
126
+ asyncio.create_task(sync_project_background(project)) for project in active_projects
127
+ ]
128
+ logger.info(f"Created {len(sync_tasks)} background sync tasks")
129
+
130
+ # Don't await the tasks - let them run in background while we continue
131
+
132
+ # Then start the watch service in the background
133
+ logger.info("Starting watch service for all projects")
134
+ # run the watch service
135
+ try:
136
+ await watch_service.run()
137
+ logger.info("Watch service started")
138
+ except Exception as e: # pragma: no cover
139
+ logger.error(f"Error starting watch service: {e}")
140
+
141
+ return None
142
+
143
+
144
+ async def initialize_app(
145
+ app_config: BasicMemoryConfig,
146
+ ):
147
+ """Initialize the Basic Memory application.
148
+
149
+ This function handles all initialization steps:
150
+ - Running database migrations
151
+ - Reconciling projects from config.json with projects table
152
+ - Setting up file synchronization
153
+ - Starting background migration for legacy project data
154
+
155
+ Args:
156
+ app_config: The Basic Memory project configuration
157
+ """
158
+ logger.info("Initializing app...")
159
+ # Initialize database first
160
+ await initialize_database(app_config)
161
+
162
+ # Reconcile projects from config.json with projects table
163
+ await reconcile_projects_with_config(app_config)
164
+
165
+ logger.info("App initialization completed (migration running in background if needed)")
166
+
167
+
168
+ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
169
+ """Ensure initialization runs in a synchronous context.
170
+
171
+ This is a wrapper for the async initialize_app function that can be
172
+ called from synchronous code like CLI entry points.
173
+
174
+ No-op if app_config.cloud_mode == True. Cloud basic memory manages it's own projects
175
+
176
+ Args:
177
+ app_config: The Basic Memory project configuration
178
+ """
179
+ # Skip initialization in cloud mode - cloud manages its own projects
180
+ if app_config.cloud_mode_enabled:
181
+ logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
182
+ return
183
+
184
+ try:
185
+ result = asyncio.run(initialize_app(app_config))
186
+ logger.info(f"Initialization completed successfully: result={result}")
187
+ except Exception as e: # pragma: no cover
188
+ logger.exception(f"Error during initialization: {e}")
189
+ # Continue execution even if initialization fails
190
+ # The command might still work, or will fail with a
191
+ # more specific error message