basic-memory 0.14.1__py3-none-any.whl → 0.14.3__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 (52) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +3 -1
  3. basic_memory/api/app.py +4 -1
  4. basic_memory/api/routers/management_router.py +3 -1
  5. basic_memory/api/routers/project_router.py +21 -13
  6. basic_memory/cli/app.py +3 -3
  7. basic_memory/cli/commands/__init__.py +1 -2
  8. basic_memory/cli/commands/db.py +5 -5
  9. basic_memory/cli/commands/import_chatgpt.py +3 -2
  10. basic_memory/cli/commands/import_claude_conversations.py +3 -1
  11. basic_memory/cli/commands/import_claude_projects.py +3 -1
  12. basic_memory/cli/commands/import_memory_json.py +5 -2
  13. basic_memory/cli/commands/mcp.py +3 -15
  14. basic_memory/cli/commands/project.py +41 -0
  15. basic_memory/cli/commands/status.py +4 -1
  16. basic_memory/cli/commands/sync.py +10 -2
  17. basic_memory/cli/main.py +0 -1
  18. basic_memory/config.py +46 -31
  19. basic_memory/db.py +2 -6
  20. basic_memory/deps.py +3 -2
  21. basic_memory/importers/chatgpt_importer.py +19 -9
  22. basic_memory/importers/memory_json_importer.py +22 -7
  23. basic_memory/mcp/async_client.py +22 -2
  24. basic_memory/mcp/project_session.py +6 -4
  25. basic_memory/mcp/prompts/__init__.py +0 -2
  26. basic_memory/mcp/server.py +8 -71
  27. basic_memory/mcp/tools/move_note.py +24 -12
  28. basic_memory/mcp/tools/project_management.py +7 -2
  29. basic_memory/mcp/tools/read_content.py +16 -0
  30. basic_memory/mcp/tools/read_note.py +12 -0
  31. basic_memory/mcp/tools/sync_status.py +3 -2
  32. basic_memory/mcp/tools/write_note.py +9 -1
  33. basic_memory/models/project.py +3 -3
  34. basic_memory/repository/project_repository.py +18 -0
  35. basic_memory/schemas/importer.py +1 -0
  36. basic_memory/services/entity_service.py +49 -3
  37. basic_memory/services/initialization.py +0 -75
  38. basic_memory/services/project_service.py +85 -28
  39. basic_memory/sync/background_sync.py +4 -3
  40. basic_memory/sync/sync_service.py +50 -1
  41. basic_memory/utils.py +107 -4
  42. {basic_memory-0.14.1.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
  43. {basic_memory-0.14.1.dist-info → basic_memory-0.14.3.dist-info}/RECORD +46 -52
  44. basic_memory/cli/commands/auth.py +0 -136
  45. basic_memory/mcp/auth_provider.py +0 -270
  46. basic_memory/mcp/external_auth_provider.py +0 -321
  47. basic_memory/mcp/prompts/sync_status.py +0 -112
  48. basic_memory/mcp/supabase_auth_provider.py +0 -463
  49. basic_memory/services/migration_service.py +0 -168
  50. {basic_memory-0.14.1.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
  51. {basic_memory-0.14.1.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
  52. {basic_memory-0.14.1.dist-info → basic_memory-0.14.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,112 +0,0 @@
1
- """Sync status prompt for Basic Memory MCP server."""
2
-
3
- from basic_memory.mcp.server import mcp
4
-
5
-
6
- @mcp.prompt(
7
- description="""Get sync status with recommendations for AI assistants.
8
-
9
- This prompt provides both current sync status and guidance on how
10
- AI assistants should respond when sync operations are in progress or completed.
11
- """,
12
- )
13
- async def sync_status_prompt() -> str:
14
- """Get sync status with AI assistant guidance.
15
- Returns:
16
- Formatted sync status with AI assistant guidance
17
- """
18
- try: # pragma: no cover
19
- from basic_memory.services.migration_service import migration_manager
20
-
21
- state = migration_manager.state
22
-
23
- # Build status report
24
- lines = [
25
- "# Basic Memory Sync Status",
26
- "",
27
- f"**Current Status**: {state.status.value.replace('_', ' ').title()}",
28
- f"**System Ready**: {'Yes' if migration_manager.is_ready else 'No'}",
29
- "",
30
- ]
31
-
32
- if migration_manager.is_ready:
33
- lines.extend(
34
- [
35
- "✅ **All sync operations completed** - System is fully operational",
36
- "",
37
- "All Basic Memory tools are available and functioning normally.",
38
- "File indexing is complete and knowledge graphs are up to date.",
39
- "You can proceed with any knowledge management tasks.",
40
- ]
41
- )
42
- else:
43
- lines.append(f"**Status Message**: {state.message}")
44
-
45
- if state.status.value == "in_progress":
46
- if state.projects_total > 0:
47
- progress = f" ({state.projects_migrated}/{state.projects_total})"
48
- lines.append(f"**Progress**: {progress}")
49
-
50
- lines.extend(
51
- [
52
- "",
53
- "🔄 **File synchronization in progress** - Processing files and building knowledge graphs",
54
- "",
55
- "**Impact**: Some tools may show status messages instead of normal responses",
56
- "until sync completes (usually 1-3 minutes).",
57
- ]
58
- )
59
-
60
- elif state.status.value == "failed":
61
- lines.extend(
62
- [
63
- "",
64
- f"❌ **Sync failed**: {state.error or 'Unknown error'}",
65
- "",
66
- "**Impact**: System may have limited functionality until issue is resolved.",
67
- ]
68
- )
69
-
70
- # Add AI assistant recommendations
71
- if not migration_manager.is_ready:
72
- lines.extend(
73
- [
74
- "",
75
- "---",
76
- "",
77
- "## AI Assistant Recommendations",
78
- "",
79
- "**When sync is in progress:**",
80
- "- Inform the user about the background file processing",
81
- "- Suggest using `sync_status()` tool to check progress",
82
- "- Explain that tools will work normally once sync completes",
83
- "- Avoid creating complex workflows until sync is done",
84
- "",
85
- "**What to tell users:**",
86
- "- 'Basic Memory is processing your files and building knowledge graphs'",
87
- "- 'This usually takes 1-3 minutes depending on your content size'",
88
- "- 'You can check progress anytime with the sync_status tool'",
89
- "- 'Full functionality will be available once processing completes'",
90
- "",
91
- "**User-friendly language:**",
92
- "- Say 'processing files' instead of 'migration' or 'sync'",
93
- "- Say 'building knowledge graphs' instead of 'indexing'",
94
- "- Say 'setting up your knowledge base' instead of 'running migrations'",
95
- ]
96
- )
97
-
98
- return "\n".join(lines)
99
-
100
- except Exception as e: # pragma: no cover
101
- return f"""# Sync Status - Error
102
-
103
- ❌ **Unable to check sync status**: {str(e)}
104
-
105
- ## AI Assistant Recommendations
106
-
107
- **When status is unavailable:**
108
- - Assume the system is likely working normally
109
- - Try proceeding with normal operations
110
- - If users report issues, suggest checking logs or restarting
111
- - Use user-friendly language about 'setting up the knowledge base'
112
- """
@@ -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
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.utcnow() + 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.utcnow().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.utcnow() + timedelta(hours=1),
457
- "iat": datetime.utcnow(),
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")