basic-memory 0.14.2__py3-none-any.whl → 0.14.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 (69) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +3 -1
  3. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
  4. basic_memory/api/app.py +4 -1
  5. basic_memory/api/routers/management_router.py +3 -1
  6. basic_memory/api/routers/project_router.py +21 -13
  7. basic_memory/api/routers/resource_router.py +3 -3
  8. basic_memory/cli/app.py +3 -3
  9. basic_memory/cli/commands/__init__.py +1 -2
  10. basic_memory/cli/commands/db.py +5 -5
  11. basic_memory/cli/commands/import_chatgpt.py +3 -2
  12. basic_memory/cli/commands/import_claude_conversations.py +3 -1
  13. basic_memory/cli/commands/import_claude_projects.py +3 -1
  14. basic_memory/cli/commands/import_memory_json.py +5 -2
  15. basic_memory/cli/commands/mcp.py +3 -15
  16. basic_memory/cli/commands/project.py +46 -6
  17. basic_memory/cli/commands/status.py +4 -1
  18. basic_memory/cli/commands/sync.py +10 -2
  19. basic_memory/cli/main.py +0 -1
  20. basic_memory/config.py +61 -34
  21. basic_memory/db.py +2 -6
  22. basic_memory/deps.py +3 -2
  23. basic_memory/file_utils.py +65 -0
  24. basic_memory/importers/chatgpt_importer.py +20 -10
  25. basic_memory/importers/memory_json_importer.py +22 -7
  26. basic_memory/importers/utils.py +2 -2
  27. basic_memory/markdown/entity_parser.py +2 -2
  28. basic_memory/markdown/markdown_processor.py +2 -2
  29. basic_memory/markdown/plugins.py +42 -26
  30. basic_memory/markdown/utils.py +1 -1
  31. basic_memory/mcp/async_client.py +22 -2
  32. basic_memory/mcp/project_session.py +6 -4
  33. basic_memory/mcp/prompts/__init__.py +0 -2
  34. basic_memory/mcp/server.py +8 -71
  35. basic_memory/mcp/tools/build_context.py +12 -2
  36. basic_memory/mcp/tools/move_note.py +24 -12
  37. basic_memory/mcp/tools/project_management.py +22 -7
  38. basic_memory/mcp/tools/read_content.py +16 -0
  39. basic_memory/mcp/tools/read_note.py +17 -2
  40. basic_memory/mcp/tools/sync_status.py +3 -2
  41. basic_memory/mcp/tools/write_note.py +9 -1
  42. basic_memory/models/knowledge.py +13 -2
  43. basic_memory/models/project.py +3 -3
  44. basic_memory/repository/entity_repository.py +2 -2
  45. basic_memory/repository/project_repository.py +19 -1
  46. basic_memory/repository/search_repository.py +7 -3
  47. basic_memory/schemas/base.py +40 -10
  48. basic_memory/schemas/importer.py +1 -0
  49. basic_memory/schemas/memory.py +23 -11
  50. basic_memory/services/context_service.py +12 -2
  51. basic_memory/services/directory_service.py +7 -0
  52. basic_memory/services/entity_service.py +56 -10
  53. basic_memory/services/initialization.py +0 -75
  54. basic_memory/services/project_service.py +93 -36
  55. basic_memory/sync/background_sync.py +4 -3
  56. basic_memory/sync/sync_service.py +53 -4
  57. basic_memory/sync/watch_service.py +31 -8
  58. basic_memory/utils.py +234 -71
  59. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
  60. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
  61. basic_memory/cli/commands/auth.py +0 -136
  62. basic_memory/mcp/auth_provider.py +0 -270
  63. basic_memory/mcp/external_auth_provider.py +0 -321
  64. basic_memory/mcp/prompts/sync_status.py +0 -112
  65. basic_memory/mcp/supabase_auth_provider.py +0 -463
  66. basic_memory/services/migration_service.py +0 -168
  67. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
  68. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
  69. {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,463 +0,0 @@
1
- """Supabase OAuth provider for Basic Memory MCP server."""
2
-
3
- import os
4
- import secrets
5
- from dataclasses import dataclass
6
- from datetime import datetime, timedelta, timezone
7
- from typing import Optional, Dict, Any
8
-
9
- import httpx
10
- import jwt
11
- from loguru import logger
12
- from mcp.server.auth.provider import (
13
- OAuthAuthorizationServerProvider,
14
- AuthorizationParams,
15
- AuthorizationCode,
16
- RefreshToken,
17
- AccessToken,
18
- TokenError,
19
- AuthorizeError,
20
- )
21
- from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
22
-
23
-
24
- @dataclass
25
- class SupabaseAuthorizationCode(AuthorizationCode):
26
- """Authorization code with Supabase metadata."""
27
-
28
- user_id: Optional[str] = None
29
- email: Optional[str] = None
30
-
31
-
32
- @dataclass
33
- class SupabaseRefreshToken(RefreshToken):
34
- """Refresh token with Supabase metadata."""
35
-
36
- supabase_refresh_token: Optional[str] = None
37
- user_id: Optional[str] = None
38
-
39
-
40
- @dataclass
41
- class SupabaseAccessToken(AccessToken):
42
- """Access token with Supabase metadata."""
43
-
44
- supabase_access_token: Optional[str] = None
45
- user_id: Optional[str] = None
46
- email: Optional[str] = None
47
-
48
-
49
- class SupabaseOAuthProvider(
50
- OAuthAuthorizationServerProvider[
51
- SupabaseAuthorizationCode, SupabaseRefreshToken, SupabaseAccessToken
52
- ]
53
- ):
54
- """OAuth provider that integrates with Supabase Auth.
55
-
56
- This provider uses Supabase as the authentication backend while
57
- maintaining compatibility with MCP's OAuth requirements.
58
- """
59
-
60
- def __init__(
61
- self,
62
- supabase_url: str,
63
- supabase_anon_key: str,
64
- supabase_service_key: Optional[str] = None,
65
- issuer_url: str = "http://localhost:8000",
66
- ):
67
- self.supabase_url = supabase_url.rstrip("/")
68
- self.supabase_anon_key = supabase_anon_key
69
- self.supabase_service_key = supabase_service_key or supabase_anon_key
70
- self.issuer_url = issuer_url
71
-
72
- # HTTP client for Supabase API calls
73
- self.http_client = httpx.AsyncClient()
74
-
75
- # Temporary storage for auth flows (in production, use Supabase DB)
76
- self.pending_auth_codes: Dict[str, SupabaseAuthorizationCode] = {}
77
- self.mcp_to_supabase_tokens: Dict[str, Dict[str, Any]] = {}
78
-
79
- async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
80
- """Get a client from Supabase.
81
-
82
- In production, this would query a clients table in Supabase.
83
- """
84
- # For now, we'll validate against a configured list of allowed clients
85
- # In production, query Supabase DB for client info
86
- allowed_clients = os.getenv("SUPABASE_ALLOWED_CLIENTS", "").split(",")
87
-
88
- if client_id in allowed_clients:
89
- return OAuthClientInformationFull(
90
- client_id=client_id,
91
- client_secret="", # Supabase handles secrets
92
- redirect_uris=[], # Supabase handles redirect URIs
93
- )
94
-
95
- return None
96
-
97
- async def register_client(self, client_info: OAuthClientInformationFull) -> None:
98
- """Register a new OAuth client in Supabase.
99
-
100
- In production, this would insert into a clients table.
101
- """
102
- # For development, we just log the registration
103
- logger.info(f"Would register client {client_info.client_id} in Supabase")
104
-
105
- # In production:
106
- # await self.supabase.table('oauth_clients').insert({
107
- # 'client_id': client_info.client_id,
108
- # 'client_secret': client_info.client_secret,
109
- # 'metadata': client_info.client_metadata,
110
- # }).execute()
111
-
112
- async def authorize(
113
- self, client: OAuthClientInformationFull, params: AuthorizationParams
114
- ) -> str:
115
- """Create authorization URL redirecting to Supabase Auth.
116
-
117
- This initiates the OAuth flow with Supabase as the identity provider.
118
- """
119
- # Generate state for this auth request
120
- state = secrets.token_urlsafe(32)
121
-
122
- # Store the authorization request
123
- self.pending_auth_codes[state] = SupabaseAuthorizationCode(
124
- code=state,
125
- scopes=params.scopes or [],
126
- expires_at=(datetime.now(timezone.utc) + timedelta(minutes=10)).timestamp(),
127
- client_id=client.client_id,
128
- code_challenge=params.code_challenge,
129
- redirect_uri=params.redirect_uri,
130
- redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
131
- )
132
-
133
- # Build Supabase auth URL
134
- auth_params = {
135
- "redirect_to": f"{self.issuer_url}/auth/callback",
136
- "scopes": " ".join(params.scopes or ["openid", "email"]),
137
- "state": state,
138
- }
139
-
140
- # Use Supabase's OAuth endpoint
141
- auth_url = f"{self.supabase_url}/auth/v1/authorize"
142
- query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
143
-
144
- return f"{auth_url}?{query_string}"
145
-
146
- async def handle_supabase_callback(self, code: str, state: str) -> str:
147
- """Handle callback from Supabase after user authentication."""
148
- # Get the original auth request
149
- auth_request = self.pending_auth_codes.get(state)
150
- if not auth_request:
151
- raise AuthorizeError(
152
- error="invalid_request",
153
- error_description="Invalid state parameter",
154
- )
155
-
156
- # Exchange code with Supabase for tokens
157
- token_response = await self.http_client.post(
158
- f"{self.supabase_url}/auth/v1/token",
159
- json={
160
- "grant_type": "authorization_code",
161
- "code": code,
162
- "redirect_uri": f"{self.issuer_url}/auth/callback",
163
- },
164
- headers={
165
- "apikey": self.supabase_anon_key,
166
- "Authorization": f"Bearer {self.supabase_anon_key}",
167
- },
168
- )
169
-
170
- if not token_response.is_success:
171
- raise AuthorizeError(
172
- error="server_error",
173
- error_description="Failed to exchange code with Supabase",
174
- )
175
-
176
- supabase_tokens = token_response.json()
177
-
178
- # Get user info from Supabase
179
- user_response = await self.http_client.get(
180
- f"{self.supabase_url}/auth/v1/user",
181
- headers={
182
- "apikey": self.supabase_anon_key,
183
- "Authorization": f"Bearer {supabase_tokens['access_token']}",
184
- },
185
- )
186
-
187
- user_data = user_response.json() if user_response.is_success else {}
188
-
189
- # Generate MCP authorization code
190
- mcp_code = secrets.token_urlsafe(32)
191
-
192
- # Update auth request with user info
193
- auth_request.code = mcp_code
194
- auth_request.user_id = user_data.get("id")
195
- auth_request.email = user_data.get("email")
196
-
197
- # Store mapping
198
- self.pending_auth_codes[mcp_code] = auth_request
199
- self.mcp_to_supabase_tokens[mcp_code] = {
200
- "supabase_tokens": supabase_tokens,
201
- "user": user_data,
202
- }
203
-
204
- # Clean up old state
205
- del self.pending_auth_codes[state]
206
-
207
- # Redirect back to client
208
- redirect_uri = str(auth_request.redirect_uri)
209
- separator = "&" if "?" in redirect_uri else "?"
210
-
211
- return f"{redirect_uri}{separator}code={mcp_code}&state={state}"
212
-
213
- async def load_authorization_code(
214
- self, client: OAuthClientInformationFull, authorization_code: str
215
- ) -> Optional[SupabaseAuthorizationCode]:
216
- """Load an authorization code."""
217
- code = self.pending_auth_codes.get(authorization_code)
218
-
219
- if code and code.client_id == client.client_id:
220
- # Check expiration
221
- if datetime.now(timezone.utc).timestamp() > code.expires_at:
222
- del self.pending_auth_codes[authorization_code]
223
- return None
224
- return code
225
-
226
- return None
227
-
228
- async def exchange_authorization_code(
229
- self, client: OAuthClientInformationFull, authorization_code: SupabaseAuthorizationCode
230
- ) -> OAuthToken:
231
- """Exchange authorization code for tokens."""
232
- # Get stored Supabase tokens
233
- token_data = self.mcp_to_supabase_tokens.get(authorization_code.code)
234
- if not token_data:
235
- raise TokenError(error="invalid_grant", error_description="Invalid authorization code")
236
-
237
- supabase_tokens = token_data["supabase_tokens"]
238
- user = token_data["user"]
239
-
240
- # Generate MCP tokens that wrap Supabase tokens
241
- access_token = self._generate_mcp_token(
242
- client_id=client.client_id,
243
- user_id=user.get("id", ""),
244
- email=user.get("email", ""),
245
- scopes=authorization_code.scopes,
246
- supabase_access_token=supabase_tokens["access_token"],
247
- )
248
-
249
- refresh_token = secrets.token_urlsafe(32)
250
-
251
- # Store the token mapping
252
- self.mcp_to_supabase_tokens[access_token] = {
253
- "client_id": client.client_id,
254
- "user_id": user.get("id"),
255
- "email": user.get("email"),
256
- "supabase_access_token": supabase_tokens["access_token"],
257
- "supabase_refresh_token": supabase_tokens["refresh_token"],
258
- "scopes": authorization_code.scopes,
259
- }
260
-
261
- # Store refresh token mapping
262
- self.mcp_to_supabase_tokens[refresh_token] = {
263
- "client_id": client.client_id,
264
- "user_id": user.get("id"),
265
- "supabase_refresh_token": supabase_tokens["refresh_token"],
266
- "scopes": authorization_code.scopes,
267
- }
268
-
269
- # Clean up authorization code
270
- del self.pending_auth_codes[authorization_code.code]
271
- del self.mcp_to_supabase_tokens[authorization_code.code]
272
-
273
- return OAuthToken(
274
- access_token=access_token,
275
- token_type="bearer",
276
- expires_in=supabase_tokens.get("expires_in", 3600),
277
- refresh_token=refresh_token,
278
- scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
279
- )
280
-
281
- async def load_refresh_token(
282
- self, client: OAuthClientInformationFull, refresh_token: str
283
- ) -> Optional[SupabaseRefreshToken]:
284
- """Load a refresh token."""
285
- token_data = self.mcp_to_supabase_tokens.get(refresh_token)
286
-
287
- if token_data and token_data["client_id"] == client.client_id:
288
- return SupabaseRefreshToken(
289
- token=refresh_token,
290
- client_id=client.client_id,
291
- scopes=token_data["scopes"],
292
- supabase_refresh_token=token_data["supabase_refresh_token"],
293
- user_id=token_data.get("user_id"),
294
- )
295
-
296
- return None
297
-
298
- async def exchange_refresh_token(
299
- self,
300
- client: OAuthClientInformationFull,
301
- refresh_token: SupabaseRefreshToken,
302
- scopes: list[str],
303
- ) -> OAuthToken:
304
- """Exchange refresh token for new tokens using Supabase."""
305
- # Refresh with Supabase
306
- token_response = await self.http_client.post(
307
- f"{self.supabase_url}/auth/v1/token",
308
- json={
309
- "grant_type": "refresh_token",
310
- "refresh_token": refresh_token.supabase_refresh_token,
311
- },
312
- headers={
313
- "apikey": self.supabase_anon_key,
314
- "Authorization": f"Bearer {self.supabase_anon_key}",
315
- },
316
- )
317
-
318
- if not token_response.is_success:
319
- raise TokenError(
320
- error="invalid_grant",
321
- error_description="Failed to refresh with Supabase",
322
- )
323
-
324
- supabase_tokens = token_response.json()
325
-
326
- # Get updated user info
327
- user_response = await self.http_client.get(
328
- f"{self.supabase_url}/auth/v1/user",
329
- headers={
330
- "apikey": self.supabase_anon_key,
331
- "Authorization": f"Bearer {supabase_tokens['access_token']}",
332
- },
333
- )
334
-
335
- user_data = user_response.json() if user_response.is_success else {}
336
-
337
- # Generate new MCP tokens
338
- new_access_token = self._generate_mcp_token(
339
- client_id=client.client_id,
340
- user_id=user_data.get("id", ""),
341
- email=user_data.get("email", ""),
342
- scopes=scopes or refresh_token.scopes,
343
- supabase_access_token=supabase_tokens["access_token"],
344
- )
345
-
346
- new_refresh_token = secrets.token_urlsafe(32)
347
-
348
- # Update token mappings
349
- self.mcp_to_supabase_tokens[new_access_token] = {
350
- "client_id": client.client_id,
351
- "user_id": user_data.get("id"),
352
- "email": user_data.get("email"),
353
- "supabase_access_token": supabase_tokens["access_token"],
354
- "supabase_refresh_token": supabase_tokens["refresh_token"],
355
- "scopes": scopes or refresh_token.scopes,
356
- }
357
-
358
- self.mcp_to_supabase_tokens[new_refresh_token] = {
359
- "client_id": client.client_id,
360
- "user_id": user_data.get("id"),
361
- "supabase_refresh_token": supabase_tokens["refresh_token"],
362
- "scopes": scopes or refresh_token.scopes,
363
- }
364
-
365
- # Clean up old tokens
366
- del self.mcp_to_supabase_tokens[refresh_token.token]
367
-
368
- return OAuthToken(
369
- access_token=new_access_token,
370
- token_type="bearer",
371
- expires_in=supabase_tokens.get("expires_in", 3600),
372
- refresh_token=new_refresh_token,
373
- scope=" ".join(scopes or refresh_token.scopes),
374
- )
375
-
376
- async def load_access_token(self, token: str) -> Optional[SupabaseAccessToken]:
377
- """Load and validate an access token."""
378
- # First check our mapping
379
- token_data = self.mcp_to_supabase_tokens.get(token)
380
- if token_data:
381
- return SupabaseAccessToken(
382
- token=token,
383
- client_id=token_data["client_id"],
384
- scopes=token_data["scopes"],
385
- supabase_access_token=token_data.get("supabase_access_token"),
386
- user_id=token_data.get("user_id"),
387
- email=token_data.get("email"),
388
- )
389
-
390
- # Try to decode as JWT
391
- try:
392
- # Verify with Supabase's JWT secret
393
- payload = jwt.decode(
394
- token,
395
- os.getenv("SUPABASE_JWT_SECRET", ""),
396
- algorithms=["HS256"],
397
- audience="authenticated",
398
- )
399
-
400
- return SupabaseAccessToken(
401
- token=token,
402
- client_id=payload.get("client_id", ""),
403
- scopes=payload.get("scopes", []),
404
- user_id=payload.get("sub"),
405
- email=payload.get("email"),
406
- )
407
- except jwt.InvalidTokenError:
408
- pass
409
-
410
- # Validate with Supabase
411
- user_response = await self.http_client.get(
412
- f"{self.supabase_url}/auth/v1/user",
413
- headers={
414
- "apikey": self.supabase_anon_key,
415
- "Authorization": f"Bearer {token}",
416
- },
417
- )
418
-
419
- if user_response.is_success:
420
- user_data = user_response.json()
421
- return SupabaseAccessToken(
422
- token=token,
423
- client_id="", # Unknown client for direct Supabase tokens
424
- scopes=[],
425
- supabase_access_token=token,
426
- user_id=user_data.get("id"),
427
- email=user_data.get("email"),
428
- )
429
-
430
- return None
431
-
432
- async def revoke_token(self, token: SupabaseAccessToken | SupabaseRefreshToken) -> None:
433
- """Revoke a token."""
434
- # Remove from our mapping
435
- self.mcp_to_supabase_tokens.pop(token.token, None)
436
-
437
- # In production, also revoke in Supabase:
438
- # await self.supabase.auth.admin.sign_out(token.user_id)
439
-
440
- def _generate_mcp_token(
441
- self,
442
- client_id: str,
443
- user_id: str,
444
- email: str,
445
- scopes: list[str],
446
- supabase_access_token: str,
447
- ) -> str:
448
- """Generate an MCP token that wraps Supabase authentication."""
449
- payload = {
450
- "iss": self.issuer_url,
451
- "sub": user_id,
452
- "client_id": client_id,
453
- "email": email,
454
- "scopes": scopes,
455
- "supabase_token": supabase_access_token[:10] + "...", # Reference only
456
- "exp": datetime.now(timezone.utc) + timedelta(hours=1),
457
- "iat": datetime.now(timezone.utc),
458
- }
459
-
460
- # Use Supabase JWT secret if available
461
- secret = os.getenv("SUPABASE_JWT_SECRET", secrets.token_urlsafe(32))
462
-
463
- return jwt.encode(payload, secret, algorithm="HS256")
@@ -1,168 +0,0 @@
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()