basic-memory 0.12.3__py3-none-any.whl → 0.13.0__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 (116) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +26 -7
  65. basic_memory/mcp/tools/recent_activity.py +11 -2
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
  110. basic_memory-0.13.0.dist-info/RECORD +138 -0
  111. basic_memory/api/routers/project_info_router.py +0 -274
  112. basic_memory/mcp/main.py +0 -24
  113. basic_memory-0.12.3.dist-info/RECORD +0 -100
  114. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  115. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  116. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,19 +5,18 @@ to ensure consistent application startup across all entry points.
5
5
  """
6
6
 
7
7
  import asyncio
8
- from typing import Optional
8
+ import shutil
9
+ from pathlib import Path
9
10
 
10
11
  from loguru import logger
11
12
 
12
13
  from basic_memory import db
13
- from basic_memory.config import ProjectConfig, config_manager
14
- from basic_memory.sync import WatchService
14
+ from basic_memory.config import BasicMemoryConfig
15
+ from basic_memory.models import Project
16
+ from basic_memory.repository import ProjectRepository
15
17
 
16
- # Import this inside functions to avoid circular imports
17
- # from basic_memory.cli.commands.sync import get_sync_service
18
18
 
19
-
20
- async def initialize_database(app_config: ProjectConfig) -> None:
19
+ async def initialize_database(app_config: BasicMemoryConfig) -> None:
21
20
  """Run database migrations to ensure schema is up to date.
22
21
 
23
22
  Args:
@@ -34,110 +33,215 @@ async def initialize_database(app_config: ProjectConfig) -> None:
34
33
  # more specific error if the database is actually unusable
35
34
 
36
35
 
36
+ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
37
+ """Ensure all projects in config.json exist in the projects table and vice versa.
38
+
39
+ This uses the ProjectService's synchronize_projects method to ensure bidirectional
40
+ synchronization between the configuration file and the database.
41
+
42
+ Args:
43
+ app_config: The Basic Memory application configuration
44
+ """
45
+ logger.info("Reconciling projects from config with database...")
46
+
47
+ # Get database session
48
+ _, session_maker = await db.get_or_create_db(
49
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
50
+ )
51
+ project_repository = ProjectRepository(session_maker)
52
+
53
+ # Import ProjectService here to avoid circular imports
54
+ from basic_memory.services.project_service import ProjectService
55
+
56
+ try:
57
+ # Create project service and synchronize projects
58
+ project_service = ProjectService(repository=project_repository)
59
+ await project_service.synchronize_projects()
60
+ logger.info("Projects successfully reconciled between config and database")
61
+ except Exception as e:
62
+ # Log the error but continue with initialization
63
+ logger.error(f"Error during project synchronization: {e}")
64
+ logger.info("Continuing with initialization despite synchronization error")
65
+
66
+
67
+ async def migrate_legacy_projects(app_config: BasicMemoryConfig):
68
+ # Get database session
69
+ _, session_maker = await db.get_or_create_db(
70
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
71
+ )
72
+ logger.info("Migrating legacy projects...")
73
+ project_repository = ProjectRepository(session_maker)
74
+
75
+ # For each project in config.json, check if it has a .basic-memory dir
76
+ for project_name, project_path in app_config.projects.items():
77
+ legacy_dir = Path(project_path) / ".basic-memory"
78
+ if not legacy_dir.exists():
79
+ continue
80
+ logger.info(f"Detected legacy project directory: {legacy_dir}")
81
+ project = await project_repository.get_by_name(project_name)
82
+ if not project: # pragma: no cover
83
+ logger.error(f"Project {project_name} not found in database, skipping migration")
84
+ continue
85
+
86
+ logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
87
+ await migrate_legacy_project_data(project, legacy_dir)
88
+ logger.info(f"Completed migration for project: {project_name}")
89
+ logger.info("Legacy projects successfully migrated")
90
+
91
+
92
+ async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> bool:
93
+ """Check if project has legacy .basic-memory dir and migrate if needed.
94
+
95
+ Args:
96
+ project: The project to check and potentially migrate
97
+
98
+ Returns:
99
+ True if migration occurred, False otherwise
100
+ """
101
+
102
+ # avoid circular imports
103
+ from basic_memory.cli.commands.sync import get_sync_service
104
+
105
+ sync_service = await get_sync_service(project)
106
+ sync_dir = Path(project.path)
107
+
108
+ logger.info(f"Sync starting project: {project.name}")
109
+ await sync_service.sync(sync_dir, project_name=project.name)
110
+ logger.info(f"Sync completed successfully for project: {project.name}")
111
+
112
+ # After successful sync, remove the legacy directory
113
+ try:
114
+ logger.info(f"Removing legacy directory: {legacy_dir}")
115
+ shutil.rmtree(legacy_dir)
116
+ return True
117
+ except Exception as e:
118
+ logger.error(f"Error removing legacy directory: {e}")
119
+ return False
120
+
121
+
37
122
  async def initialize_file_sync(
38
- app_config: ProjectConfig,
39
- ) -> asyncio.Task:
40
- """Initialize file synchronization services.
123
+ app_config: BasicMemoryConfig,
124
+ ):
125
+ """Initialize file synchronization services. This function starts the watch service and does not return
41
126
 
42
127
  Args:
43
128
  app_config: The Basic Memory project configuration
44
129
 
45
130
  Returns:
46
- Tuple of (sync_service, watch_service, watch_task) if sync is enabled,
47
- or (None, None, None) if sync is disabled
131
+ The watch service task that's monitoring file changes
48
132
  """
49
- # Load app configuration
50
- # Import here to avoid circular imports
51
- from basic_memory.cli.commands.sync import get_sync_service
52
133
 
53
- # Initialize sync service
54
- sync_service = await get_sync_service()
134
+ # delay import
135
+ from basic_memory.sync import WatchService
136
+
137
+ # Load app configuration
138
+ _, session_maker = await db.get_or_create_db(
139
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
140
+ )
141
+ project_repository = ProjectRepository(session_maker)
55
142
 
56
143
  # Initialize watch service
57
144
  watch_service = WatchService(
58
- sync_service=sync_service,
59
- file_service=sync_service.entity_service.file_service,
60
- config=app_config,
145
+ app_config=app_config,
146
+ project_repository=project_repository,
61
147
  quiet=True,
62
148
  )
63
149
 
64
- # Create the background task for running sync
65
- async def run_background_sync(): # pragma: no cover
66
- # Run initial full sync
67
- await sync_service.sync(app_config.home)
68
- logger.info("Sync completed successfully")
150
+ # Get active projects
151
+ active_projects = await project_repository.get_active_projects()
152
+
153
+ # First, sync all projects sequentially
154
+ for project in active_projects:
155
+ # avoid circular imports
156
+ from basic_memory.cli.commands.sync import get_sync_service
157
+
158
+ logger.info(f"Starting sync for project: {project.name}")
159
+ sync_service = await get_sync_service(project)
160
+ sync_dir = Path(project.path)
69
161
 
70
- # Start background sync task
71
- logger.info(f"Starting watch service to sync file changes in dir: {app_config.home}")
162
+ try:
163
+ await sync_service.sync(sync_dir, project_name=project.name)
164
+ logger.info(f"Sync completed successfully for project: {project.name}")
72
165
 
73
- # Start watching for changes
166
+ # Mark project as watching for changes after successful sync
167
+ from basic_memory.services.sync_status_service import sync_status_tracker
168
+
169
+ sync_status_tracker.start_project_watch(project.name)
170
+ logger.info(f"Project {project.name} is now watching for changes")
171
+ except Exception as e: # pragma: no cover
172
+ logger.error(f"Error syncing project {project.name}: {e}")
173
+ # Mark sync as failed for this project
174
+ from basic_memory.services.sync_status_service import sync_status_tracker
175
+
176
+ sync_status_tracker.fail_project_sync(project.name, str(e))
177
+ # Continue with other projects even if one fails
178
+
179
+ # Mark migration complete if it was in progress
180
+ try:
181
+ from basic_memory.services.migration_service import migration_manager
182
+
183
+ if not migration_manager.is_ready: # pragma: no cover
184
+ migration_manager.mark_completed("Migration completed with file sync")
185
+ logger.info("Marked migration as completed after file sync")
186
+ except Exception as e: # pragma: no cover
187
+ logger.warning(f"Could not update migration status: {e}")
188
+
189
+ # Then start the watch service in the background
190
+ logger.info("Starting watch service for all projects")
191
+ # run the watch service
192
+ try:
74
193
  await watch_service.run()
194
+ logger.info("Watch service started")
195
+ except Exception as e: # pragma: no cover
196
+ logger.error(f"Error starting watch service: {e}")
75
197
 
76
- watch_task = asyncio.create_task(run_background_sync())
77
- logger.info("Watch service started")
78
- return watch_task
198
+ return None
79
199
 
80
200
 
81
201
  async def initialize_app(
82
- app_config: ProjectConfig,
83
- ) -> Optional[asyncio.Task]:
202
+ app_config: BasicMemoryConfig,
203
+ ):
84
204
  """Initialize the Basic Memory application.
85
205
 
86
- This function handles all initialization steps needed for both API and shor lived CLI commands.
87
- For long running commands like mcp, a
206
+ This function handles all initialization steps:
88
207
  - Running database migrations
208
+ - Reconciling projects from config.json with projects table
89
209
  - Setting up file synchronization
210
+ - Starting background migration for legacy project data
90
211
 
91
212
  Args:
92
213
  app_config: The Basic Memory project configuration
93
214
  """
215
+ logger.info("Initializing app...")
94
216
  # Initialize database first
95
217
  await initialize_database(app_config)
96
218
 
97
- basic_memory_config = config_manager.load_config()
98
- logger.info(f"Sync changes enabled: {basic_memory_config.sync_changes}")
99
- logger.info(
100
- f"Update permalinks on move enabled: {basic_memory_config.update_permalinks_on_move}"
101
- )
102
- if not basic_memory_config.sync_changes: # pragma: no cover
103
- logger.info("Sync changes disabled. Skipping watch service.")
104
- return
219
+ # Reconcile projects from config.json with projects table
220
+ await reconcile_projects_with_config(app_config)
105
221
 
106
- # Initialize file sync services
107
- return await initialize_file_sync(app_config)
222
+ # Start background migration for legacy project data (non-blocking)
223
+ from basic_memory.services.migration_service import migration_manager
108
224
 
225
+ await migration_manager.start_background_migration(app_config)
109
226
 
110
- def ensure_initialization(app_config: ProjectConfig) -> None:
111
- """Ensure initialization runs in a synchronous context.
227
+ logger.info("App initialization completed (migration running in background if needed)")
228
+ return migration_manager
112
229
 
113
- This is a wrapper for the async initialize_app function that can be
114
- called from synchronous code like CLI entry points.
115
230
 
116
- Args:
117
- app_config: The Basic Memory project configuration
118
- """
119
- try:
120
- asyncio.run(initialize_app(app_config))
121
- except Exception as e:
122
- logger.error(f"Error during initialization: {e}")
123
- # Continue execution even if initialization fails
124
- # The command might still work, or will fail with a
125
- # more specific error message
126
-
127
-
128
- def ensure_initialize_database(app_config: ProjectConfig) -> None:
231
+ def ensure_initialization(app_config: BasicMemoryConfig) -> None:
129
232
  """Ensure initialization runs in a synchronous context.
130
233
 
131
- This is a wrapper for the async initialize_database function that can be
234
+ This is a wrapper for the async initialize_app function that can be
132
235
  called from synchronous code like CLI entry points.
133
236
 
134
237
  Args:
135
238
  app_config: The Basic Memory project configuration
136
239
  """
137
240
  try:
138
- asyncio.run(initialize_database(app_config))
139
- except Exception as e:
140
- logger.error(f"Error during initialization: {e}")
241
+ result = asyncio.run(initialize_app(app_config))
242
+ logger.info(f"Initialization completed successfully: result={result}")
243
+ except Exception as e: # pragma: no cover
244
+ logger.exception(f"Error during initialization: {e}")
141
245
  # Continue execution even if initialization fails
142
246
  # The command might still work, or will fail with a
143
247
  # more specific error message
@@ -15,10 +15,10 @@ class LinkResolver:
15
15
 
16
16
  Uses a combination of exact matching and search-based resolution:
17
17
  1. Try exact permalink match (fastest)
18
- 2. Try permalink pattern match (for wildcards)
19
- 3. Try exact title match
20
- 4. Fall back to search for fuzzy matching
21
- 5. Generate new permalink if no match found
18
+ 2. Try exact title match
19
+ 3. Try exact file path match
20
+ 4. Try file path with .md extension (for folder/title patterns)
21
+ 5. Fall back to search for fuzzy matching
22
22
  """
23
23
 
24
24
  def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -26,9 +26,17 @@ class LinkResolver:
26
26
  self.entity_repository = entity_repository
27
27
  self.search_service = search_service
28
28
 
29
- async def resolve_link(self, link_text: str, use_search: bool = True) -> Optional[Entity]:
30
- """Resolve a markdown link to a permalink."""
31
- logger.debug(f"Resolving link: {link_text}")
29
+ async def resolve_link(
30
+ self, link_text: str, use_search: bool = True, strict: bool = False
31
+ ) -> Optional[Entity]:
32
+ """Resolve a markdown link to a permalink.
33
+
34
+ Args:
35
+ link_text: The link text to resolve
36
+ use_search: Whether to use search-based fuzzy matching as fallback
37
+ strict: If True, only exact matches are allowed (no fuzzy search fallback)
38
+ """
39
+ logger.trace(f"Resolving link: {link_text}")
32
40
 
33
41
  # Clean link text and extract any alias
34
42
  clean_text, alias = self._normalize_link_text(link_text)
@@ -41,7 +49,8 @@ class LinkResolver:
41
49
 
42
50
  # 2. Try exact title match
43
51
  found = await self.entity_repository.get_by_title(clean_text)
44
- if found and len(found) == 1:
52
+ if found:
53
+ # Return first match if there are duplicates (consistent behavior)
45
54
  entity = found[0]
46
55
  logger.debug(f"Found title match: {entity.title}")
47
56
  return entity
@@ -52,17 +61,28 @@ class LinkResolver:
52
61
  logger.debug(f"Found entity with path: {found_path.file_path}")
53
62
  return found_path
54
63
 
55
- # search if indicated
64
+ # 4. Try file path with .md extension if not already present
65
+ if not clean_text.endswith(".md") and "/" in clean_text:
66
+ file_path_with_md = f"{clean_text}.md"
67
+ found_path_md = await self.entity_repository.get_by_file_path(file_path_with_md)
68
+ if found_path_md:
69
+ logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
70
+ return found_path_md
71
+
72
+ # In strict mode, don't try fuzzy search - return None if no exact match found
73
+ if strict:
74
+ return None
75
+
76
+ # 5. Fall back to search for fuzzy matching (only if not in strict mode)
56
77
  if use_search and "*" not in clean_text:
57
- # 3. Fall back to search for fuzzy matching on title
58
78
  results = await self.search_service.search(
59
- query=SearchQuery(title=clean_text, entity_types=[SearchItemType.ENTITY]),
79
+ query=SearchQuery(text=clean_text, entity_types=[SearchItemType.ENTITY]),
60
80
  )
61
81
 
62
82
  if results:
63
83
  # Look for best match
64
84
  best_match = min(results, key=lambda x: x.score) # pyright: ignore
65
- logger.debug(
85
+ logger.trace(
66
86
  f"Selected best match from {len(results)} results: {best_match.permalink}"
67
87
  )
68
88
  if best_match.permalink:
@@ -93,5 +113,8 @@ class LinkResolver:
93
113
  text, alias = text.split("|", 1)
94
114
  text = text.strip()
95
115
  alias = alias.strip()
116
+ else:
117
+ # Strip whitespace from text even if no alias
118
+ text = text.strip()
96
119
 
97
120
  return text, alias
@@ -0,0 +1,168 @@
1
+ """Migration service for handling background migrations and status tracking."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from loguru import logger
10
+
11
+ from basic_memory.config import BasicMemoryConfig
12
+
13
+
14
+ class MigrationStatus(Enum):
15
+ """Status of migration operations."""
16
+
17
+ NOT_NEEDED = "not_needed"
18
+ PENDING = "pending"
19
+ IN_PROGRESS = "in_progress"
20
+ COMPLETED = "completed"
21
+ FAILED = "failed"
22
+
23
+
24
+ @dataclass
25
+ class MigrationState:
26
+ """Current state of migration operations."""
27
+
28
+ status: MigrationStatus
29
+ message: str
30
+ progress: Optional[str] = None
31
+ error: Optional[str] = None
32
+ projects_migrated: int = 0
33
+ projects_total: int = 0
34
+
35
+
36
+ class MigrationManager:
37
+ """Manages background migration operations and status tracking."""
38
+
39
+ def __init__(self):
40
+ self._state = MigrationState(
41
+ status=MigrationStatus.NOT_NEEDED, message="No migration required"
42
+ )
43
+ self._migration_task: Optional[asyncio.Task] = None
44
+
45
+ @property
46
+ def state(self) -> MigrationState:
47
+ """Get current migration state."""
48
+ return self._state
49
+
50
+ @property
51
+ def is_ready(self) -> bool:
52
+ """Check if the system is ready for normal operations."""
53
+ return self._state.status in (MigrationStatus.NOT_NEEDED, MigrationStatus.COMPLETED)
54
+
55
+ @property
56
+ def status_message(self) -> str:
57
+ """Get a user-friendly status message."""
58
+ if self._state.status == MigrationStatus.IN_PROGRESS:
59
+ progress = (
60
+ f" ({self._state.projects_migrated}/{self._state.projects_total})"
61
+ if self._state.projects_total > 0
62
+ else ""
63
+ )
64
+ return f"🔄 File sync in progress{progress}: {self._state.message}. Use sync_status() tool for details."
65
+ elif self._state.status == MigrationStatus.FAILED:
66
+ return f"❌ File sync failed: {self._state.error or 'Unknown error'}. Use sync_status() tool for details."
67
+ elif self._state.status == MigrationStatus.COMPLETED:
68
+ return "✅ File sync completed successfully"
69
+ else:
70
+ return "✅ System ready"
71
+
72
+ async def check_migration_needed(self, app_config: BasicMemoryConfig) -> bool:
73
+ """Check if migration is needed without performing it."""
74
+ from basic_memory import db
75
+ from basic_memory.repository import ProjectRepository
76
+
77
+ try:
78
+ # Get database session
79
+ _, session_maker = await db.get_or_create_db(
80
+ db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
81
+ )
82
+ project_repository = ProjectRepository(session_maker)
83
+
84
+ # Check for legacy projects
85
+ legacy_projects = []
86
+ for project_name, project_path in app_config.projects.items():
87
+ legacy_dir = Path(project_path) / ".basic-memory"
88
+ if legacy_dir.exists():
89
+ project = await project_repository.get_by_name(project_name)
90
+ if project:
91
+ legacy_projects.append(project)
92
+
93
+ if legacy_projects:
94
+ self._state = MigrationState(
95
+ status=MigrationStatus.PENDING,
96
+ message="Legacy projects detected",
97
+ projects_total=len(legacy_projects),
98
+ )
99
+ return True
100
+ else:
101
+ self._state = MigrationState(
102
+ status=MigrationStatus.NOT_NEEDED, message="No migration required"
103
+ )
104
+ return False
105
+
106
+ except Exception as e:
107
+ logger.error(f"Error checking migration status: {e}")
108
+ self._state = MigrationState(
109
+ status=MigrationStatus.FAILED, message="Migration check failed", error=str(e)
110
+ )
111
+ return False
112
+
113
+ async def start_background_migration(self, app_config: BasicMemoryConfig) -> None:
114
+ """Start migration in background if needed."""
115
+ if not await self.check_migration_needed(app_config):
116
+ return
117
+
118
+ if self._migration_task and not self._migration_task.done():
119
+ logger.info("Migration already in progress")
120
+ return
121
+
122
+ logger.info("Starting background migration")
123
+ self._migration_task = asyncio.create_task(self._run_migration(app_config))
124
+
125
+ async def _run_migration(self, app_config: BasicMemoryConfig) -> None:
126
+ """Run the actual migration process."""
127
+ try:
128
+ self._state.status = MigrationStatus.IN_PROGRESS
129
+ self._state.message = "Migrating legacy projects"
130
+
131
+ # Import here to avoid circular imports
132
+ from basic_memory.services.initialization import migrate_legacy_projects
133
+
134
+ # Run the migration
135
+ await migrate_legacy_projects(app_config)
136
+
137
+ self._state = MigrationState(
138
+ status=MigrationStatus.COMPLETED, message="Migration completed successfully"
139
+ )
140
+ logger.info("Background migration completed successfully")
141
+
142
+ except Exception as e:
143
+ logger.error(f"Background migration failed: {e}")
144
+ self._state = MigrationState(
145
+ status=MigrationStatus.FAILED, message="Migration failed", error=str(e)
146
+ )
147
+
148
+ async def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
149
+ """Wait for migration to complete."""
150
+ if self.is_ready:
151
+ return True
152
+
153
+ if not self._migration_task:
154
+ return False
155
+
156
+ try:
157
+ await asyncio.wait_for(self._migration_task, timeout=timeout)
158
+ return self.is_ready
159
+ except asyncio.TimeoutError:
160
+ return False
161
+
162
+ def mark_completed(self, message: str = "Migration completed") -> None:
163
+ """Mark migration as completed externally."""
164
+ self._state = MigrationState(status=MigrationStatus.COMPLETED, message=message)
165
+
166
+
167
+ # Global migration manager instance
168
+ migration_manager = MigrationManager()