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
@@ -9,7 +9,6 @@ from typing import Dict, Optional, Sequence
9
9
  from loguru import logger
10
10
  from sqlalchemy import text
11
11
 
12
- from basic_memory.config import config, app_config
13
12
  from basic_memory.models import Project
14
13
  from basic_memory.repository.project_repository import ProjectRepository
15
14
  from basic_memory.schemas import (
@@ -18,9 +17,8 @@ from basic_memory.schemas import (
18
17
  ProjectStatistics,
19
18
  SystemStatus,
20
19
  )
21
- from basic_memory.config import WATCH_STATUS_JSON
20
+ from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
22
21
  from basic_memory.utils import generate_permalink
23
- from basic_memory.config import config_manager
24
22
 
25
23
 
26
24
  class ProjectService:
@@ -33,6 +31,24 @@ class ProjectService:
33
31
  super().__init__()
34
32
  self.repository = repository
35
33
 
34
+ @property
35
+ def config_manager(self) -> ConfigManager:
36
+ """Get a ConfigManager instance.
37
+
38
+ Returns:
39
+ Fresh ConfigManager instance for each access
40
+ """
41
+ return ConfigManager()
42
+
43
+ @property
44
+ def config(self) -> ProjectConfig:
45
+ """Get the current project configuration.
46
+
47
+ Returns:
48
+ Current project configuration
49
+ """
50
+ return get_project_config()
51
+
36
52
  @property
37
53
  def projects(self) -> Dict[str, str]:
38
54
  """Get all configured projects.
@@ -40,7 +56,7 @@ class ProjectService:
40
56
  Returns:
41
57
  Dict mapping project names to their file paths
42
58
  """
43
- return config_manager.projects
59
+ return self.config_manager.projects
44
60
 
45
61
  @property
46
62
  def default_project(self) -> str:
@@ -49,7 +65,7 @@ class ProjectService:
49
65
  Returns:
50
66
  The name of the default project
51
67
  """
52
- return config_manager.default_project
68
+ return self.config_manager.default_project
53
69
 
54
70
  @property
55
71
  def current_project(self) -> str:
@@ -58,7 +74,7 @@ class ProjectService:
58
74
  Returns:
59
75
  The name of the current project
60
76
  """
61
- return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
77
+ return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
62
78
 
63
79
  async def list_projects(self) -> Sequence[Project]:
64
80
  return await self.repository.find_all()
@@ -84,10 +100,10 @@ class ProjectService:
84
100
  raise ValueError("Repository is required for add_project")
85
101
 
86
102
  # Resolve to absolute path
87
- resolved_path = os.path.abspath(os.path.expanduser(path))
103
+ resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
88
104
 
89
105
  # First add to config file (this will validate the project doesn't exist)
90
- project_config = config_manager.add_project(name, resolved_path)
106
+ project_config = self.config_manager.add_project(name, resolved_path)
91
107
 
92
108
  # Then add to database
93
109
  project_data = {
@@ -103,7 +119,7 @@ class ProjectService:
103
119
  # If this should be the default project, ensure only one default exists
104
120
  if set_default:
105
121
  await self.repository.set_as_default(created_project.id)
106
- config_manager.set_default_project(name)
122
+ self.config_manager.set_default_project(name)
107
123
  logger.info(f"Project '{name}' set as default")
108
124
 
109
125
  logger.info(f"Project '{name}' added at {resolved_path}")
@@ -121,10 +137,10 @@ class ProjectService:
121
137
  raise ValueError("Repository is required for remove_project")
122
138
 
123
139
  # First remove from config (this will validate the project exists and is not default)
124
- config_manager.remove_project(name)
140
+ self.config_manager.remove_project(name)
125
141
 
126
- # Then remove from database
127
- project = await self.repository.get_by_name(name)
142
+ # Then remove from database using robust lookup
143
+ project = await self.get_project(name)
128
144
  if project:
129
145
  await self.repository.delete(project.id)
130
146
 
@@ -143,10 +159,10 @@ class ProjectService:
143
159
  raise ValueError("Repository is required for set_default_project")
144
160
 
145
161
  # First update config file (this will validate the project exists)
146
- config_manager.set_default_project(name)
162
+ self.config_manager.set_default_project(name)
147
163
 
148
- # Then update database
149
- project = await self.repository.get_by_name(name)
164
+ # Then update database using the same lookup logic as get_project
165
+ project = await self.get_project(name)
150
166
  if project:
151
167
  await self.repository.set_as_default(project.id)
152
168
  else:
@@ -196,7 +212,7 @@ class ProjectService:
196
212
  elif len(default_projects) == 0: # pragma: no cover
197
213
  # No default project - set the config default as default
198
214
  # This is defensive code for edge cases where no default exists
199
- config_default = config_manager.default_project # pragma: no cover
215
+ config_default = self.config_manager.default_project # pragma: no cover
200
216
  config_project = await self.repository.get_by_name(config_default) # pragma: no cover
201
217
  if config_project: # pragma: no cover
202
218
  await self.repository.set_as_default(config_project.id) # pragma: no cover
@@ -221,7 +237,7 @@ class ProjectService:
221
237
  db_projects_by_permalink = {p.permalink: p for p in db_projects}
222
238
 
223
239
  # Get all projects from configuration and normalize names if needed
224
- config_projects = config_manager.projects.copy()
240
+ config_projects = self.config_manager.projects.copy()
225
241
  updated_config = {}
226
242
  config_updated = False
227
243
 
@@ -237,8 +253,9 @@ class ProjectService:
237
253
 
238
254
  # Update the configuration if any changes were made
239
255
  if config_updated:
240
- config_manager.config.projects = updated_config
241
- config_manager.save_config(config_manager.config)
256
+ config = self.config_manager.load_config()
257
+ config.projects = updated_config
258
+ self.config_manager.save_config(config)
242
259
  logger.info("Config updated with normalized project names")
243
260
 
244
261
  # Use the normalized config for further processing
@@ -261,19 +278,19 @@ class ProjectService:
261
278
  for name, project in db_projects_by_permalink.items():
262
279
  if name not in config_projects:
263
280
  logger.info(f"Adding project '{name}' to configuration")
264
- config_manager.add_project(name, project.path)
281
+ self.config_manager.add_project(name, project.path)
265
282
 
266
283
  # Ensure database default project state is consistent
267
284
  await self._ensure_single_default_project()
268
285
 
269
286
  # Make sure default project is synchronized between config and database
270
287
  db_default = await self.repository.get_default_project()
271
- config_default = config_manager.default_project
288
+ config_default = self.config_manager.default_project
272
289
 
273
290
  if db_default and db_default.name != config_default:
274
291
  # Update config to match DB default
275
292
  logger.info(f"Updating default project in config to '{db_default.name}'")
276
- config_manager.set_default_project(db_default.name)
293
+ self.config_manager.set_default_project(db_default.name)
277
294
  elif not db_default and config_default:
278
295
  # Update DB to match config default (if the project exists)
279
296
  project = await self.repository.get_by_name(config_default)
@@ -292,6 +309,47 @@ class ProjectService:
292
309
  # MCP components might not be available in all contexts
293
310
  logger.debug("MCP session not available, skipping session refresh")
294
311
 
312
+ async def move_project(self, name: str, new_path: str) -> None:
313
+ """Move a project to a new location.
314
+
315
+ Args:
316
+ name: The name of the project to move
317
+ new_path: The new absolute path for the project
318
+
319
+ Raises:
320
+ ValueError: If the project doesn't exist or repository isn't initialized
321
+ """
322
+ if not self.repository:
323
+ raise ValueError("Repository is required for move_project")
324
+
325
+ # Resolve to absolute path
326
+ resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
327
+
328
+ # Validate project exists in config
329
+ if name not in self.config_manager.projects:
330
+ raise ValueError(f"Project '{name}' not found in configuration")
331
+
332
+ # Create the new directory if it doesn't exist
333
+ Path(resolved_path).mkdir(parents=True, exist_ok=True)
334
+
335
+ # Update in configuration
336
+ config = self.config_manager.load_config()
337
+ old_path = config.projects[name]
338
+ config.projects[name] = resolved_path
339
+ self.config_manager.save_config(config)
340
+
341
+ # Update in database using robust lookup
342
+ project = await self.get_project(name)
343
+ if project:
344
+ await self.repository.update_path(project.id, resolved_path)
345
+ logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
346
+ else:
347
+ logger.error(f"Project '{name}' exists in config but not in database")
348
+ # Restore the old path in config since DB update failed
349
+ config.projects[name] = old_path
350
+ self.config_manager.save_config(config)
351
+ raise ValueError(f"Project '{name}' not found in database")
352
+
295
353
  async def update_project( # pragma: no cover
296
354
  self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
297
355
  ) -> None:
@@ -309,24 +367,23 @@ class ProjectService:
309
367
  raise ValueError("Repository is required for update_project")
310
368
 
311
369
  # Validate project exists in config
312
- if name not in config_manager.projects:
370
+ if name not in self.config_manager.projects:
313
371
  raise ValueError(f"Project '{name}' not found in configuration")
314
372
 
315
- # Get project from database
316
- project = await self.repository.get_by_name(name)
373
+ # Get project from database using robust lookup
374
+ project = await self.get_project(name)
317
375
  if not project:
318
376
  logger.error(f"Project '{name}' exists in config but not in database")
319
377
  return
320
378
 
321
379
  # Update path if provided
322
380
  if updated_path:
323
- resolved_path = os.path.abspath(os.path.expanduser(updated_path))
381
+ resolved_path = Path(os.path.abspath(os.path.expanduser(updated_path))).as_posix()
324
382
 
325
383
  # Update in config
326
- projects = config_manager.config.projects.copy()
327
- projects[name] = resolved_path
328
- config_manager.config.projects = projects
329
- config_manager.save_config(config_manager.config)
384
+ config = self.config_manager.load_config()
385
+ config.projects[name] = resolved_path
386
+ self.config_manager.save_config(config)
330
387
 
331
388
  # Update in database
332
389
  project.path = resolved_path
@@ -347,7 +404,7 @@ class ProjectService:
347
404
  if active_projects:
348
405
  new_default = active_projects[0]
349
406
  await self.repository.set_as_default(new_default.id)
350
- config_manager.set_default_project(new_default.name)
407
+ self.config_manager.set_default_project(new_default.name)
351
408
  logger.info(
352
409
  f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
353
410
  )
@@ -365,9 +422,9 @@ class ProjectService:
365
422
  raise ValueError("Repository is required for get_project_info")
366
423
 
367
424
  # Use specified project or fall back to config project
368
- project_name = project_name or config.project
425
+ project_name = project_name or self.config.project
369
426
  # Get project path from configuration
370
- name, project_path = config_manager.get_project(project_name)
427
+ name, project_path = self.config_manager.get_project(project_name)
371
428
  if not name: # pragma: no cover
372
429
  raise ValueError(f"Project '{project_name}' not found in configuration")
373
430
 
@@ -393,11 +450,11 @@ class ProjectService:
393
450
  db_projects_by_permalink = {p.permalink: p for p in db_projects}
394
451
 
395
452
  # Get default project info
396
- default_project = config_manager.default_project
453
+ default_project = self.config_manager.default_project
397
454
 
398
455
  # Convert config projects to include database info
399
456
  enhanced_projects = {}
400
- for name, path in config_manager.projects.items():
457
+ for name, path in self.config_manager.projects.items():
401
458
  config_permalink = generate_permalink(name)
402
459
  db_project = db_projects_by_permalink.get(config_permalink)
403
460
  enhanced_projects[name] = {
@@ -673,7 +730,7 @@ class ProjectService:
673
730
  import basic_memory
674
731
 
675
732
  # Get database information
676
- db_path = app_config.database_path
733
+ db_path = self.config_manager.config.database_path
677
734
  db_size = db_path.stat().st_size if db_path.exists() else 0
678
735
  db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
679
736
 
@@ -2,7 +2,7 @@ import asyncio
2
2
 
3
3
  from loguru import logger
4
4
 
5
- from basic_memory.config import config as project_config
5
+ from basic_memory.config import get_project_config
6
6
  from basic_memory.sync import SyncService, WatchService
7
7
 
8
8
 
@@ -11,9 +11,10 @@ async def sync_and_watch(
11
11
  ): # pragma: no cover
12
12
  """Run sync and watch service."""
13
13
 
14
- logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
14
+ config = get_project_config()
15
+ logger.info(f"Starting watch service to sync file changes in dir: {config.home}")
15
16
  # full sync
16
- await sync_service.sync(project_config.home)
17
+ await sync_service.sync(config.home)
17
18
 
18
19
  # watch changes
19
20
  await watch_service.run()
@@ -357,8 +357,8 @@ class SyncService:
357
357
 
358
358
  # get file timestamps
359
359
  file_stats = self.file_service.file_stats(path)
360
- created = datetime.fromtimestamp(file_stats.st_ctime)
361
- modified = datetime.fromtimestamp(file_stats.st_mtime)
360
+ created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
361
+ modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
362
362
 
363
363
  # get mime type
364
364
  content_type = self.file_service.content_type(path)
@@ -453,6 +453,36 @@ class SyncService:
453
453
 
454
454
  entity = await self.entity_repository.get_by_file_path(old_path)
455
455
  if entity:
456
+ # Check if destination path is already occupied by another entity
457
+ existing_at_destination = await self.entity_repository.get_by_file_path(new_path)
458
+ if existing_at_destination and existing_at_destination.id != entity.id:
459
+ # Handle the conflict - this could be a file swap or replacement scenario
460
+ logger.warning(
461
+ f"File path conflict detected during move: "
462
+ f"entity_id={entity.id} trying to move from '{old_path}' to '{new_path}', "
463
+ f"but entity_id={existing_at_destination.id} already occupies '{new_path}'"
464
+ )
465
+
466
+ # Check if this is a file swap (the destination entity is being moved to our old path)
467
+ # This would indicate a simultaneous move operation
468
+ old_path_after_swap = await self.entity_repository.get_by_file_path(old_path)
469
+ if old_path_after_swap and old_path_after_swap.id == existing_at_destination.id:
470
+ logger.info(f"Detected file swap between '{old_path}' and '{new_path}'")
471
+ # This is a swap scenario - both moves should succeed
472
+ # We'll allow this to proceed since the other file has moved out
473
+ else:
474
+ # This is a conflict where the destination is occupied
475
+ raise ValueError(
476
+ f"Cannot move entity from '{old_path}' to '{new_path}': "
477
+ f"destination path is already occupied by another file. "
478
+ f"This may be caused by: "
479
+ f"1. Conflicting file names with different character encodings, "
480
+ f"2. Case sensitivity differences (e.g., 'Finance/' vs 'finance/'), "
481
+ f"3. Character conflicts between hyphens in filenames and generated permalinks, "
482
+ f"4. Files with similar names containing special characters. "
483
+ f"Try renaming one of the conflicting files to resolve this issue."
484
+ )
485
+
456
486
  # Update file_path in all cases
457
487
  updates = {"file_path": new_path}
458
488
 
@@ -477,7 +507,26 @@ class SyncService:
477
507
  f"new_checksum={new_checksum}"
478
508
  )
479
509
 
480
- updated = await self.entity_repository.update(entity.id, updates)
510
+ try:
511
+ updated = await self.entity_repository.update(entity.id, updates)
512
+ except Exception as e:
513
+ # Catch any database integrity errors and provide helpful context
514
+ if "UNIQUE constraint failed" in str(e):
515
+ logger.error(
516
+ f"Database constraint violation during move: "
517
+ f"entity_id={entity.id}, old_path='{old_path}', new_path='{new_path}'"
518
+ )
519
+ raise ValueError(
520
+ f"Cannot complete move from '{old_path}' to '{new_path}': "
521
+ f"a database constraint was violated. This usually indicates "
522
+ f"a file path or permalink conflict. Please check for: "
523
+ f"1. Duplicate file names, "
524
+ f"2. Case sensitivity issues (e.g., 'File.md' vs 'file.md'), "
525
+ f"3. Character encoding conflicts in file names."
526
+ ) from e
527
+ else:
528
+ # Re-raise other exceptions as-is
529
+ raise
481
530
 
482
531
  if updated is None: # pragma: no cover
483
532
  logger.error(
@@ -570,7 +619,7 @@ class SyncService:
570
619
  continue
571
620
 
572
621
  path = Path(root) / filename
573
- rel_path = str(path.relative_to(directory))
622
+ rel_path = path.relative_to(directory).as_posix()
574
623
  checksum = await self.file_service.compute_checksum(rel_path)
575
624
  result.files[rel_path] = checksum
576
625
  result.checksums[checksum] = rel_path
@@ -197,7 +197,7 @@ class WatchService:
197
197
 
198
198
  for change, path in changes:
199
199
  # convert to relative path
200
- relative_path = str(Path(path).relative_to(directory))
200
+ relative_path = Path(path).relative_to(directory).as_posix()
201
201
 
202
202
  # Skip .tmp files - they're temporary and shouldn't be synced
203
203
  if relative_path.endswith(".tmp"):
@@ -284,13 +284,36 @@ class WatchService:
284
284
  # Process deletes
285
285
  for path in deletes:
286
286
  if path not in processed:
287
- logger.debug("Processing deleted file", path=path)
288
- await sync_service.handle_delete(path)
289
- self.state.add_event(path=path, action="deleted", status="success")
290
- self.console.print(f"[red]✕[/red] {path}")
291
- logger.info(f"deleted: {path}")
292
- processed.add(path)
293
- delete_count += 1
287
+ # Check if file still exists on disk (vim atomic write edge case)
288
+ full_path = directory / path
289
+ if full_path.exists() and full_path.is_file():
290
+ # File still exists despite DELETE event - treat as modification
291
+ logger.debug("File exists despite DELETE event, treating as modification", path=path)
292
+ entity, checksum = await sync_service.sync_file(path, new=False)
293
+ self.state.add_event(path=path, action="modified", status="success", checksum=checksum)
294
+ self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
295
+ logger.info(f"atomic write detected: {path}")
296
+ processed.add(path)
297
+ modify_count += 1
298
+ else:
299
+ # Check if this was a directory - skip if so
300
+ # (we can't tell if the deleted path was a directory since it no longer exists,
301
+ # so we check if there's an entity in the database for it)
302
+ entity = await sync_service.entity_repository.get_by_file_path(path)
303
+ if entity is None:
304
+ # No entity means this was likely a directory - skip it
305
+ logger.debug(f"Skipping deleted path with no entity (likely directory), path={path}")
306
+ processed.add(path)
307
+ continue
308
+
309
+ # File truly deleted
310
+ logger.debug("Processing deleted file", path=path)
311
+ await sync_service.handle_delete(path)
312
+ self.state.add_event(path=path, action="deleted", status="success")
313
+ self.console.print(f"[red]✕[/red] {path}")
314
+ logger.info(f"deleted: {path}")
315
+ processed.add(path)
316
+ delete_count += 1
294
317
 
295
318
  # Process adds
296
319
  for path in adds: