basic-memory 0.16.1__py3-none-any.whl → 0.17.4__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -3,15 +3,19 @@
3
3
  import asyncio
4
4
  import hashlib
5
5
  import mimetypes
6
- from os import stat_result
6
+ from datetime import datetime
7
7
  from pathlib import Path
8
- from typing import Any, Dict, Tuple, Union
8
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
9
9
 
10
10
  import aiofiles
11
+
11
12
  import yaml
12
13
 
13
14
  from basic_memory import file_utils
14
- from basic_memory.file_utils import FileError, ParseError
15
+
16
+ if TYPE_CHECKING: # pragma: no cover
17
+ from basic_memory.config import BasicMemoryConfig
18
+ from basic_memory.file_utils import FileError, FileMetadata, ParseError
15
19
  from basic_memory.markdown.markdown_processor import MarkdownProcessor
16
20
  from basic_memory.models import Entity as EntityModel
17
21
  from basic_memory.schemas import Entity as EntitySchema
@@ -41,9 +45,11 @@ class FileService:
41
45
  base_path: Path,
42
46
  markdown_processor: MarkdownProcessor,
43
47
  max_concurrent_files: int = 10,
48
+ app_config: Optional["BasicMemoryConfig"] = None,
44
49
  ):
45
50
  self.base_path = base_path.resolve() # Get absolute path
46
51
  self.markdown_processor = markdown_processor
52
+ self.app_config = app_config
47
53
  # Semaphore to limit concurrent file operations
48
54
  # Prevents OOM on large projects by processing files in batches
49
55
  self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
@@ -148,12 +154,15 @@ class FileService:
148
154
  Handles both absolute and relative paths. Relative paths are resolved
149
155
  against base_path.
150
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
+
151
160
  Args:
152
161
  path: Where to write (Path or string)
153
162
  content: Content to write
154
163
 
155
164
  Returns:
156
- Checksum of written content
165
+ Checksum of written content (or formatted content if formatting enabled)
157
166
 
158
167
  Raises:
159
168
  FileOperationError: If write fails
@@ -176,8 +185,17 @@ class FileService:
176
185
 
177
186
  await file_utils.write_file_atomic(full_path, content)
178
187
 
179
- # Compute and return checksum
180
- checksum = await file_utils.compute_checksum(content)
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 # pragma: no cover
196
+
197
+ # Compute and return checksum of final content
198
+ checksum = await file_utils.compute_checksum(final_content)
181
199
  logger.debug(f"File write completed path={full_path}, {checksum=}")
182
200
  return checksum
183
201
 
@@ -216,6 +234,45 @@ class FileService:
216
234
  )
217
235
  return content
218
236
 
237
+ except FileNotFoundError:
238
+ # Preserve FileNotFoundError so callers (e.g. sync) can treat it as deletion.
239
+ logger.warning("File not found", operation="read_file_content", path=str(full_path))
240
+ raise
241
+ except Exception as e:
242
+ logger.exception("File read error", path=str(full_path), error=str(e))
243
+ raise FileOperationError(f"Failed to read file: {e}")
244
+
245
+ async def read_file_bytes(self, path: FilePath) -> bytes:
246
+ """Read file content as bytes using true async I/O with aiofiles.
247
+
248
+ This method reads files in binary mode, suitable for non-text files
249
+ like images, PDFs, etc. For cloud compatibility with S3FileService.
250
+
251
+ Args:
252
+ path: Path to read (Path or string)
253
+
254
+ Returns:
255
+ File content as bytes
256
+
257
+ Raises:
258
+ FileOperationError: If read fails
259
+ """
260
+ # Convert string to Path if needed
261
+ path_obj = self.base_path / path if isinstance(path, str) else path
262
+ full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
263
+
264
+ try:
265
+ logger.debug("Reading file bytes", operation="read_file_bytes", path=str(full_path))
266
+ async with aiofiles.open(full_path, mode="rb") as f:
267
+ content = await f.read()
268
+
269
+ logger.debug(
270
+ "File read completed",
271
+ path=str(full_path),
272
+ content_length=len(content),
273
+ )
274
+ return content
275
+
219
276
  except Exception as e:
220
277
  logger.exception("File read error", path=str(full_path), error=str(e))
221
278
  raise FileOperationError(f"Failed to read file: {e}")
@@ -276,6 +333,43 @@ class FileService:
276
333
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
277
334
  full_path.unlink(missing_ok=True)
278
335
 
336
+ async def move_file(self, source: FilePath, destination: FilePath) -> None:
337
+ """Move/rename a file from source to destination.
338
+
339
+ This method abstracts the underlying storage (filesystem vs cloud).
340
+ Default implementation uses atomic filesystem rename, but cloud-backed
341
+ implementations (e.g., S3) can override to copy+delete.
342
+
343
+ Args:
344
+ source: Source path (relative to base_path or absolute)
345
+ destination: Destination path (relative to base_path or absolute)
346
+
347
+ Raises:
348
+ FileOperationError: If the move fails
349
+ """
350
+ # Convert strings to Paths and resolve relative paths against base_path
351
+ src_obj = self.base_path / source if isinstance(source, str) else source
352
+ dst_obj = self.base_path / destination if isinstance(destination, str) else destination
353
+ src_full = src_obj if src_obj.is_absolute() else self.base_path / src_obj
354
+ dst_full = dst_obj if dst_obj.is_absolute() else self.base_path / dst_obj
355
+
356
+ try:
357
+ # Ensure destination directory exists
358
+ await self.ensure_directory(dst_full.parent)
359
+
360
+ # Use semaphore for concurrency control and run blocking rename in executor
361
+ async with self._file_semaphore:
362
+ loop = asyncio.get_event_loop()
363
+ await loop.run_in_executor(None, lambda: src_full.rename(dst_full))
364
+ except Exception as e: # pragma: no cover
365
+ logger.exception(
366
+ "File move error",
367
+ source=str(src_full),
368
+ destination=str(dst_full),
369
+ error=str(e),
370
+ )
371
+ raise FileOperationError(f"Failed to move file {source} -> {destination}: {e}")
372
+
279
373
  async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
280
374
  """Update frontmatter fields in a file while preserving all content.
281
375
 
@@ -311,14 +405,14 @@ class FileService:
311
405
  try:
312
406
  current_fm = file_utils.parse_frontmatter(content)
313
407
  content = file_utils.remove_frontmatter(content)
314
- except (ParseError, yaml.YAMLError) as e:
408
+ except (ParseError, yaml.YAMLError) as e: # pragma: no cover
315
409
  # Log warning and treat as plain markdown without frontmatter
316
- logger.warning(
410
+ logger.warning( # pragma: no cover
317
411
  f"Failed to parse YAML frontmatter in {full_path}: {e}. "
318
412
  "Treating file as plain markdown without frontmatter."
319
413
  )
320
414
  # Keep full content, treat as having no frontmatter
321
- current_fm = {}
415
+ current_fm = {} # pragma: no cover
322
416
 
323
417
  # Update frontmatter
324
418
  new_fm = {**current_fm, **updates}
@@ -332,9 +426,19 @@ class FileService:
332
426
  )
333
427
 
334
428
  await file_utils.write_file_atomic(full_path, final_content)
335
- return await file_utils.compute_checksum(final_content)
336
429
 
337
- except Exception as e:
430
+ # Format file if configured
431
+ content_for_checksum = final_content
432
+ if self.app_config:
433
+ formatted_content = await file_utils.format_file(
434
+ full_path, self.app_config, is_markdown=self.is_markdown(path)
435
+ )
436
+ if formatted_content is not None:
437
+ content_for_checksum = formatted_content # pragma: no cover
438
+
439
+ return await file_utils.compute_checksum(content_for_checksum)
440
+
441
+ except Exception as e: # pragma: no cover
338
442
  # Only log real errors (not YAML parsing, which is handled above)
339
443
  if not isinstance(e, (ParseError, yaml.YAMLError)):
340
444
  logger.error(
@@ -381,20 +485,31 @@ class FileService:
381
485
  logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
382
486
  raise FileError(f"Failed to compute checksum for {path}: {e}")
383
487
 
384
- def file_stats(self, path: FilePath) -> stat_result:
385
- """Return file stats for a given path.
488
+ async def get_file_metadata(self, path: FilePath) -> FileMetadata:
489
+ """Return file metadata for a given path.
490
+
491
+ This method is async to support cloud implementations (S3FileService)
492
+ where file metadata requires async operations (head_object).
386
493
 
387
494
  Args:
388
495
  path: Path to the file (Path or string)
389
496
 
390
497
  Returns:
391
- File statistics
498
+ FileMetadata with size, created_at, and modified_at
392
499
  """
393
500
  # Convert string to Path if needed
394
501
  path_obj = self.base_path / path if isinstance(path, str) else path
395
502
  full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
396
- # get file timestamps
397
- return full_path.stat()
503
+
504
+ # Run blocking stat() in thread pool to maintain async compatibility
505
+ loop = asyncio.get_event_loop()
506
+ stat_result = await loop.run_in_executor(None, full_path.stat)
507
+
508
+ return FileMetadata(
509
+ size=stat_result.st_size,
510
+ created_at=datetime.fromtimestamp(stat_result.st_ctime).astimezone(),
511
+ modified_at=datetime.fromtimestamp(stat_result.st_mtime).astimezone(),
512
+ )
398
513
 
399
514
  def content_type(self, path: FilePath) -> str:
400
515
  """Return content_type for a given path.
@@ -5,8 +5,11 @@ to ensure consistent application startup across all entry points.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import os
9
+ import sys
8
10
  from pathlib import Path
9
11
 
12
+
10
13
  from loguru import logger
11
14
 
12
15
  from basic_memory import db
@@ -27,15 +30,12 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None:
27
30
  Database migrations are now handled automatically when the database
28
31
  connection is first established via get_or_create_db().
29
32
  """
30
- # Trigger database initialization and migrations by getting the database connection
31
33
  try:
32
34
  await db.get_or_create_db(app_config.database_path)
33
35
  logger.info("Database initialization completed")
34
36
  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
37
+ logger.error(f"Error during database initialization: {e}")
38
+ raise
39
39
 
40
40
 
41
41
  async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
@@ -49,31 +49,29 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
49
49
  """
50
50
  logger.info("Reconciling projects from config with database...")
51
51
 
52
- # Get database session - migrations handled centrally
52
+ # Get database session (engine already created by initialize_database)
53
53
  _, session_maker = await db.get_or_create_db(
54
54
  db_path=app_config.database_path,
55
55
  db_type=db.DatabaseType.FILESYSTEM,
56
- ensure_migrations=False,
57
56
  )
58
57
  project_repository = ProjectRepository(session_maker)
59
58
 
60
59
  # Import ProjectService here to avoid circular imports
61
60
  from basic_memory.services.project_service import ProjectService
62
61
 
62
+ # Create project service and synchronize projects
63
+ project_service = ProjectService(repository=project_repository)
63
64
  try:
64
- # Create project service and synchronize projects
65
- project_service = ProjectService(repository=project_repository)
66
65
  await project_service.synchronize_projects()
67
66
  logger.info("Projects successfully reconciled between config and database")
68
67
  except Exception as e:
69
- # Log the error but continue with initialization
70
68
  logger.error(f"Error during project synchronization: {e}")
71
69
  logger.info("Continuing with initialization despite synchronization error")
72
70
 
73
71
 
74
72
  async def initialize_file_sync(
75
73
  app_config: BasicMemoryConfig,
76
- ):
74
+ ) -> None:
77
75
  """Initialize file synchronization services. This function starts the watch service and does not return
78
76
 
79
77
  Args:
@@ -82,15 +80,20 @@ async def initialize_file_sync(
82
80
  Returns:
83
81
  The watch service task that's monitoring file changes
84
82
  """
83
+ # Never start file watching during tests. Even "background" watchers add tasks/threads
84
+ # and can interact badly with strict asyncio teardown (especially on Windows/aiosqlite).
85
+ # Skip file sync in test environments to avoid interference with tests
86
+ if app_config.is_test_env:
87
+ logger.info("Test environment detected - skipping file sync initialization")
88
+ return None
85
89
 
86
90
  # delay import
87
91
  from basic_memory.sync import WatchService
88
92
 
89
- # Load app configuration - migrations handled centrally
93
+ # Get database session (migrations already run if needed)
90
94
  _, session_maker = await db.get_or_create_db(
91
95
  db_path=app_config.database_path,
92
96
  db_type=db.DatabaseType.FILESYSTEM,
93
- ensure_migrations=False,
94
97
  )
95
98
  project_repository = ProjectRepository(session_maker)
96
99
 
@@ -104,6 +107,12 @@ async def initialize_file_sync(
104
107
  # Get active projects
105
108
  active_projects = await project_repository.get_active_projects()
106
109
 
110
+ # Filter to constrained project if MCP server was started with --project
111
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
112
+ if constrained_project:
113
+ active_projects = [p for p in active_projects if p.name == constrained_project]
114
+ logger.info(f"Background sync constrained to project: {constrained_project}")
115
+
107
116
  # Start sync for all projects as background tasks (non-blocking)
108
117
  async def sync_project_background(project: Project):
109
118
  """Sync a single project in the background."""
@@ -131,12 +140,10 @@ async def initialize_file_sync(
131
140
 
132
141
  # Then start the watch service in the background
133
142
  logger.info("Starting watch service for all projects")
143
+
134
144
  # 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}")
145
+ await watch_service.run()
146
+ logger.info("Watch service started")
140
147
 
141
148
  return None
142
149
 
@@ -155,6 +162,11 @@ async def initialize_app(
155
162
  Args:
156
163
  app_config: The Basic Memory project configuration
157
164
  """
165
+ # Skip initialization in cloud mode - cloud manages its own projects
166
+ if app_config.cloud_mode_enabled:
167
+ logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
168
+ return
169
+
158
170
  logger.info("Initializing app...")
159
171
  # Initialize database first
160
172
  await initialize_database(app_config)
@@ -181,11 +193,24 @@ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
181
193
  logger.debug("Skipping initialization in cloud mode - projects managed by cloud")
182
194
  return
183
195
 
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
196
+ async def _init_and_cleanup():
197
+ """Initialize app and clean up database connections.
198
+
199
+ Database connections created during initialization must be cleaned up
200
+ before the event loop closes, otherwise the process will hang indefinitely.
201
+ """
202
+ try:
203
+ await initialize_app(app_config)
204
+ finally:
205
+ # Always cleanup database connections to prevent process hang
206
+ await db.shutdown_db()
207
+
208
+ # On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
209
+ # The ProactorEventLoop can raise "IndexError: pop from an empty deque" during
210
+ # event loop cleanup when there are pending handles. SelectorEventLoop is more
211
+ # stable for our use case (no subprocess pipes or named pipes needed).
212
+ if sys.platform == "win32":
213
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
214
+
215
+ asyncio.run(_init_and_cleanup())
216
+ logger.info("Initialization completed successfully")
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import Optional, Tuple
4
4
 
5
+
5
6
  from loguru import logger
6
7
 
7
8
  from basic_memory.models import Entity
@@ -8,6 +8,7 @@ from datetime import datetime
8
8
  from pathlib import Path
9
9
  from typing import Dict, Optional, Sequence
10
10
 
11
+
11
12
  from loguru import logger
12
13
  from sqlalchemy import text
13
14
 
@@ -23,9 +24,6 @@ from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_co
23
24
  from basic_memory.utils import generate_permalink
24
25
 
25
26
 
26
- config = ConfigManager().config
27
-
28
-
29
27
  class ProjectService:
30
28
  """Service for managing Basic Memory projects."""
31
29
 
@@ -46,7 +44,7 @@ class ProjectService:
46
44
  return ConfigManager()
47
45
 
48
46
  @property
49
- def config(self) -> ProjectConfig:
47
+ def config(self) -> ProjectConfig: # pragma: no cover
50
48
  """Get the current project configuration.
51
49
 
52
50
  Returns:
@@ -143,6 +141,7 @@ class ProjectService:
143
141
  """
144
142
  # If project_root is set, constrain all projects to that directory
145
143
  project_root = self.config_manager.config.project_root
144
+ sanitized_name = None
146
145
  if project_root:
147
146
  base_path = Path(project_root)
148
147
 
@@ -155,11 +154,11 @@ class ProjectService:
155
154
  resolved_path = (base_path / sanitized_name).resolve().as_posix()
156
155
 
157
156
  # Verify the resolved path is actually under project_root
158
- if not resolved_path.startswith(base_path.resolve().as_posix()):
157
+ if not resolved_path.startswith(base_path.resolve().as_posix()): # pragma: no cover
159
158
  raise ValueError(
160
159
  f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
161
160
  f"All projects must be created under this directory. Invalid path: {path}"
162
- )
161
+ ) # pragma: no cover
163
162
 
164
163
  # Check for case-insensitive path collisions with existing projects
165
164
  existing_projects = await self.list_projects()
@@ -168,11 +167,11 @@ class ProjectService:
168
167
  existing.path.lower() == resolved_path.lower()
169
168
  and existing.path != resolved_path
170
169
  ):
171
- raise ValueError(
170
+ raise ValueError( # pragma: no cover
172
171
  f"Path collision detected: '{resolved_path}' conflicts with existing project "
173
172
  f"'{existing.name}' at '{existing.path}'. "
174
173
  f"In cloud mode, paths are normalized to lowercase to prevent case-sensitivity issues."
175
- )
174
+ ) # pragma: no cover
176
175
  else:
177
176
  resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
178
177
 
@@ -199,14 +198,15 @@ class ProjectService:
199
198
  f"Projects cannot share directory trees."
200
199
  )
201
200
 
202
- # First add to config file (this will validate the project doesn't exist)
203
- project_config = self.config_manager.add_project(name, resolved_path)
201
+ if not self.config_manager.config.cloud_mode:
202
+ # First add to config file (this will validate the project doesn't exist)
203
+ self.config_manager.add_project(name, resolved_path)
204
204
 
205
205
  # Then add to database
206
206
  project_data = {
207
207
  "name": name,
208
208
  "path": resolved_path,
209
- "permalink": generate_permalink(project_config.name),
209
+ "permalink": sanitized_name,
210
210
  "is_active": True,
211
211
  # Don't set is_default=False to avoid UNIQUE constraint issues
212
212
  # Let it default to NULL, only set to True when explicitly making default
@@ -237,20 +237,22 @@ class ProjectService:
237
237
  # Get project from database first
238
238
  project = await self.get_project(name)
239
239
  if not project:
240
- raise ValueError(f"Project '{name}' not found")
240
+ raise ValueError(f"Project '{name}' not found") # pragma: no cover
241
241
 
242
242
  project_path = project.path
243
243
 
244
244
  # Check if project is default (in cloud mode, check database; in local mode, check config)
245
245
  if project.is_default or name == self.config_manager.config.default_project:
246
- raise ValueError(f"Cannot remove the default project '{name}'")
246
+ raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
247
247
 
248
248
  # Remove from config if it exists there (may not exist in cloud mode)
249
249
  try:
250
250
  self.config_manager.remove_project(name)
251
- except ValueError:
251
+ except ValueError: # pragma: no cover
252
252
  # Project not in config - that's OK in cloud mode, continue with database deletion
253
- logger.debug(f"Project '{name}' not found in config, removing from database only")
253
+ logger.debug( # pragma: no cover
254
+ f"Project '{name}' not found in config, removing from database only"
255
+ )
254
256
 
255
257
  # Remove from database
256
258
  await self.repository.delete(project.id)
@@ -265,11 +267,13 @@ class ProjectService:
265
267
  await asyncio.to_thread(shutil.rmtree, project_path)
266
268
  logger.info(f"Deleted project directory: {project_path}")
267
269
  else:
268
- logger.warning(
270
+ logger.warning( # pragma: no cover
269
271
  f"Project directory not found or not a directory: {project_path}"
270
- )
271
- except Exception as e:
272
- logger.warning(f"Failed to delete project directory {project_path}: {e}")
272
+ ) # pragma: no cover
273
+ except Exception as e: # pragma: no cover
274
+ logger.warning( # pragma: no cover
275
+ f"Failed to delete project directory {project_path}: {e}"
276
+ )
273
277
 
274
278
  async def set_default_project(self, name: str) -> None:
275
279
  """Set the default project in configuration and database.
@@ -283,15 +287,17 @@ class ProjectService:
283
287
  if not self.repository: # pragma: no cover
284
288
  raise ValueError("Repository is required for set_default_project")
285
289
 
286
- # First update config file (this will validate the project exists)
287
- self.config_manager.set_default_project(name)
288
-
289
- # Then update database using the same lookup logic as get_project
290
+ # Look up project in database first to validate it exists
290
291
  project = await self.get_project(name)
291
- if project:
292
- await self.repository.set_as_default(project.id)
293
- else:
294
- logger.error(f"Project '{name}' exists in config but not in database")
292
+ if not project:
293
+ raise ValueError(f"Project '{name}' not found")
294
+
295
+ # Update database
296
+ await self.repository.set_as_default(project.id)
297
+
298
+ # Update config file only in local mode (cloud mode uses database only)
299
+ if not self.config_manager.config.cloud_mode:
300
+ self.config_manager.set_default_project(name)
295
301
 
296
302
  logger.info(f"Project '{name}' set as default in configuration and database")
297
303
 
@@ -430,8 +436,8 @@ class ProjectService:
430
436
  Raises:
431
437
  ValueError: If the project doesn't exist or repository isn't initialized
432
438
  """
433
- if not self.repository:
434
- raise ValueError("Repository is required for move_project")
439
+ if not self.repository: # pragma: no cover
440
+ raise ValueError("Repository is required for move_project") # pragma: no cover
435
441
 
436
442
  # Resolve to absolute path
437
443
  resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
@@ -766,25 +772,42 @@ class ProjectService:
766
772
  )
767
773
 
768
774
  # Query for monthly entity creation (project filtered)
775
+ # Use different date formatting for SQLite vs Postgres
776
+ from basic_memory.config import DatabaseBackend
777
+
778
+ is_postgres = self.config_manager.config.database_backend == DatabaseBackend.POSTGRES
779
+ date_format = (
780
+ "to_char(created_at, 'YYYY-MM')" if is_postgres else "strftime('%Y-%m', created_at)"
781
+ )
782
+
783
+ # Postgres needs datetime objects, SQLite needs ISO strings
784
+ six_months_param = six_months_ago if is_postgres else six_months_ago.isoformat()
785
+
769
786
  entity_growth_result = await self.repository.execute_query(
770
- text("""
771
- SELECT
772
- strftime('%Y-%m', created_at) AS month,
787
+ text(f"""
788
+ SELECT
789
+ {date_format} AS month,
773
790
  COUNT(*) AS count
774
791
  FROM entity
775
792
  WHERE created_at >= :six_months_ago AND project_id = :project_id
776
793
  GROUP BY month
777
794
  ORDER BY month
778
795
  """),
779
- {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
796
+ {"six_months_ago": six_months_param, "project_id": project_id},
780
797
  )
781
798
  entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
782
799
 
783
800
  # Query for monthly observation creation (project filtered)
801
+ date_format_entity = (
802
+ "to_char(entity.created_at, 'YYYY-MM')"
803
+ if is_postgres
804
+ else "strftime('%Y-%m', entity.created_at)"
805
+ )
806
+
784
807
  observation_growth_result = await self.repository.execute_query(
785
- text("""
786
- SELECT
787
- strftime('%Y-%m', entity.created_at) AS month,
808
+ text(f"""
809
+ SELECT
810
+ {date_format_entity} AS month,
788
811
  COUNT(*) AS count
789
812
  FROM observation
790
813
  INNER JOIN entity ON observation.entity_id = entity.id
@@ -792,15 +815,15 @@ class ProjectService:
792
815
  GROUP BY month
793
816
  ORDER BY month
794
817
  """),
795
- {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
818
+ {"six_months_ago": six_months_param, "project_id": project_id},
796
819
  )
797
820
  observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
798
821
 
799
822
  # Query for monthly relation creation (project filtered)
800
823
  relation_growth_result = await self.repository.execute_query(
801
- text("""
802
- SELECT
803
- strftime('%Y-%m', entity.created_at) AS month,
824
+ text(f"""
825
+ SELECT
826
+ {date_format_entity} AS month,
804
827
  COUNT(*) AS count
805
828
  FROM relation
806
829
  INNER JOIN entity ON relation.from_id = entity.id
@@ -808,7 +831,7 @@ class ProjectService:
808
831
  GROUP BY month
809
832
  ORDER BY month
810
833
  """),
811
- {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
834
+ {"six_months_ago": six_months_param, "project_id": project_id},
812
835
  )
813
836
  relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
814
837
 
@@ -849,8 +872,10 @@ class ProjectService:
849
872
  watch_status = None
850
873
  watch_status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
851
874
  if watch_status_path.exists():
852
- try:
853
- watch_status = json.loads(watch_status_path.read_text(encoding="utf-8"))
875
+ try: # pragma: no cover
876
+ watch_status = json.loads( # pragma: no cover
877
+ watch_status_path.read_text(encoding="utf-8")
878
+ )
854
879
  except Exception: # pragma: no cover
855
880
  pass
856
881