basic-memory 0.14.2__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 (51) 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/read_content.py +16 -0
  29. basic_memory/mcp/tools/read_note.py +12 -0
  30. basic_memory/mcp/tools/sync_status.py +3 -2
  31. basic_memory/mcp/tools/write_note.py +9 -1
  32. basic_memory/models/project.py +3 -3
  33. basic_memory/repository/project_repository.py +18 -0
  34. basic_memory/schemas/importer.py +1 -0
  35. basic_memory/services/entity_service.py +49 -3
  36. basic_memory/services/initialization.py +0 -75
  37. basic_memory/services/project_service.py +85 -28
  38. basic_memory/sync/background_sync.py +4 -3
  39. basic_memory/sync/sync_service.py +50 -1
  40. basic_memory/utils.py +105 -4
  41. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
  42. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/RECORD +45 -51
  43. basic_memory/cli/commands/auth.py +0 -136
  44. basic_memory/mcp/auth_provider.py +0 -270
  45. basic_memory/mcp/external_auth_provider.py +0 -321
  46. basic_memory/mcp/prompts/sync_status.py +0 -112
  47. basic_memory/mcp/supabase_auth_provider.py +0 -463
  48. basic_memory/services/migration_service.py +0 -168
  49. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
  50. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
  51. {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/licenses/LICENSE +0 -0
@@ -4,8 +4,10 @@ from typing import Optional
4
4
 
5
5
  from loguru import logger
6
6
 
7
+ from basic_memory.config import ConfigManager
7
8
  from basic_memory.mcp.server import mcp
8
9
  from basic_memory.mcp.project_session import get_active_project
10
+ from basic_memory.services.sync_status_service import sync_status_tracker
9
11
 
10
12
 
11
13
  def _get_all_projects_status() -> list[str]:
@@ -13,8 +15,7 @@ def _get_all_projects_status() -> list[str]:
13
15
  status_lines = []
14
16
 
15
17
  try:
16
- from basic_memory.config import app_config
17
- from basic_memory.services.sync_status_service import sync_status_tracker
18
+ app_config = ConfigManager().config
18
19
 
19
20
  if app_config.projects:
20
21
  status_lines.extend(["", "---", "", "**All Projects Status:**"])
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools.utils import call_put
10
10
  from basic_memory.mcp.project_session import get_active_project
11
11
  from basic_memory.schemas import EntityResponse
12
12
  from basic_memory.schemas.base import Entity
13
- from basic_memory.utils import parse_tags
13
+ from basic_memory.utils import parse_tags, validate_project_path
14
14
 
15
15
  # Define TagType as a Union that can accept either a string or a list of strings or None
16
16
  TagType = Union[List[str], str, None]
@@ -75,6 +75,14 @@ async def write_note(
75
75
  # Get the active project first to check project-specific sync status
76
76
  active_project = get_active_project(project)
77
77
 
78
+ # Validate folder path to prevent path traversal attacks
79
+ project_path = active_project.home
80
+ if folder and not validate_project_path(folder, project_path):
81
+ logger.warning(
82
+ "Attempted path traversal attack blocked", folder=folder, project=active_project.name
83
+ )
84
+ return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
85
+
78
86
  # Check migration status and wait briefly if needed
79
87
  from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
80
88
 
@@ -1,6 +1,6 @@
1
1
  """Project model for Basic Memory."""
2
2
 
3
- from datetime import datetime
3
+ from datetime import datetime, UTC
4
4
  from typing import Optional
5
5
 
6
6
  from sqlalchemy import (
@@ -52,9 +52,9 @@ class Project(Base):
52
52
  is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
53
53
 
54
54
  # Timestamps
55
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
55
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
56
56
  updated_at: Mapped[datetime] = mapped_column(
57
- DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
57
+ DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
58
58
  )
59
59
 
60
60
  # Define relationships to entities, observations, and relations
@@ -83,3 +83,21 @@ class ProjectRepository(Repository[Project]):
83
83
  await session.flush()
84
84
  return target_project
85
85
  return None # pragma: no cover
86
+
87
+ async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
88
+ """Update project path.
89
+
90
+ Args:
91
+ project_id: ID of the project to update
92
+ new_path: New filesystem path for the project
93
+
94
+ Returns:
95
+ The updated project if found, None otherwise
96
+ """
97
+ async with db.scoped_session(self.session_maker) as session:
98
+ project = await self.select_by_id(session, project_id)
99
+ if project:
100
+ project.path = new_path
101
+ await session.flush()
102
+ return project
103
+ return None
@@ -32,3 +32,4 @@ class EntityImportResult(ImportResult):
32
32
 
33
33
  entities: int = 0
34
34
  relations: int = 0
35
+ skipped_entities: int = 0
@@ -15,6 +15,7 @@ from basic_memory.markdown.entity_parser import EntityParser
15
15
  from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
16
16
  from basic_memory.models import Entity as EntityModel
17
17
  from basic_memory.models import Observation, Relation
18
+ from basic_memory.models.knowledge import Entity
18
19
  from basic_memory.repository import ObservationRepository, RelationRepository
19
20
  from basic_memory.repository.entity_repository import EntityRepository
20
21
  from basic_memory.schemas import Entity as EntitySchema
@@ -44,6 +45,39 @@ class EntityService(BaseService[EntityModel]):
44
45
  self.file_service = file_service
45
46
  self.link_resolver = link_resolver
46
47
 
48
+ async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
49
+ """Detect potential file path conflicts for a given file path.
50
+
51
+ This checks for entities with similar file paths that might cause conflicts:
52
+ - Case sensitivity differences (Finance/file.md vs finance/file.md)
53
+ - Character encoding differences
54
+ - Hyphen vs space differences
55
+ - Unicode normalization differences
56
+
57
+ Args:
58
+ file_path: The file path to check for conflicts
59
+
60
+ Returns:
61
+ List of entities that might conflict with the given file path
62
+ """
63
+ from basic_memory.utils import detect_potential_file_conflicts
64
+
65
+ conflicts = []
66
+
67
+ # Get all existing file paths
68
+ all_entities = await self.repository.find_all()
69
+ existing_paths = [entity.file_path for entity in all_entities]
70
+
71
+ # Use the enhanced conflict detection utility
72
+ conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
73
+
74
+ # Find the entities corresponding to conflicting paths
75
+ for entity in all_entities:
76
+ if entity.file_path in conflicting_paths:
77
+ conflicts.append(entity)
78
+
79
+ return conflicts
80
+
47
81
  async def resolve_permalink(
48
82
  self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
49
83
  ) -> str:
@@ -54,18 +88,30 @@ class EntityService(BaseService[EntityModel]):
54
88
  2. If markdown has permalink but it's used by another file -> make unique
55
89
  3. For existing files, keep current permalink from db
56
90
  4. Generate new unique permalink from file path
91
+
92
+ Enhanced to detect and handle character-related conflicts.
57
93
  """
94
+ file_path_str = str(file_path)
95
+
96
+ # Check for potential file path conflicts before resolving permalink
97
+ conflicts = await self.detect_file_path_conflicts(file_path_str)
98
+ if conflicts:
99
+ logger.warning(
100
+ f"Detected potential file path conflicts for '{file_path_str}': "
101
+ f"{[entity.file_path for entity in conflicts]}"
102
+ )
103
+
58
104
  # If markdown has explicit permalink, try to validate it
59
105
  if markdown and markdown.frontmatter.permalink:
60
106
  desired_permalink = markdown.frontmatter.permalink
61
107
  existing = await self.repository.get_by_permalink(desired_permalink)
62
108
 
63
109
  # If no conflict or it's our own file, use as is
64
- if not existing or existing.file_path == str(file_path):
110
+ if not existing or existing.file_path == file_path_str:
65
111
  return desired_permalink
66
112
 
67
113
  # For existing files, try to find current permalink
68
- existing = await self.repository.get_by_file_path(str(file_path))
114
+ existing = await self.repository.get_by_file_path(file_path_str)
69
115
  if existing:
70
116
  return existing.permalink
71
117
 
@@ -75,7 +121,7 @@ class EntityService(BaseService[EntityModel]):
75
121
  else:
76
122
  desired_permalink = generate_permalink(file_path)
77
123
 
78
- # Make unique if needed
124
+ # Make unique if needed - enhanced to handle character conflicts
79
125
  permalink = desired_permalink
80
126
  suffix = 1
81
127
  while await self.repository.get_by_permalink(permalink):
@@ -5,14 +5,12 @@ to ensure consistent application startup across all entry points.
5
5
  """
6
6
 
7
7
  import asyncio
8
- import shutil
9
8
  from pathlib import Path
10
9
 
11
10
  from loguru import logger
12
11
 
13
12
  from basic_memory import db
14
13
  from basic_memory.config import BasicMemoryConfig
15
- from basic_memory.models import Project
16
14
  from basic_memory.repository import ProjectRepository
17
15
 
18
16
 
@@ -70,63 +68,6 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
70
68
  logger.info("Continuing with initialization despite synchronization error")
71
69
 
72
70
 
73
- async def migrate_legacy_projects(app_config: BasicMemoryConfig):
74
- # Get database session - migrations handled centrally
75
- _, session_maker = await db.get_or_create_db(
76
- db_path=app_config.database_path,
77
- db_type=db.DatabaseType.FILESYSTEM,
78
- ensure_migrations=False,
79
- )
80
- logger.info("Migrating legacy projects...")
81
- project_repository = ProjectRepository(session_maker)
82
-
83
- # For each project in config.json, check if it has a .basic-memory dir
84
- for project_name, project_path in app_config.projects.items():
85
- legacy_dir = Path(project_path) / ".basic-memory"
86
- if not legacy_dir.exists():
87
- continue
88
- logger.info(f"Detected legacy project directory: {legacy_dir}")
89
- project = await project_repository.get_by_name(project_name)
90
- if not project: # pragma: no cover
91
- logger.error(f"Project {project_name} not found in database, skipping migration")
92
- continue
93
-
94
- logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
95
- await migrate_legacy_project_data(project, legacy_dir)
96
- logger.info(f"Completed migration for project: {project_name}")
97
- logger.info("Legacy projects successfully migrated")
98
-
99
-
100
- async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> bool:
101
- """Check if project has legacy .basic-memory dir and migrate if needed.
102
-
103
- Args:
104
- project: The project to check and potentially migrate
105
-
106
- Returns:
107
- True if migration occurred, False otherwise
108
- """
109
-
110
- # avoid circular imports
111
- from basic_memory.cli.commands.sync import get_sync_service
112
-
113
- sync_service = await get_sync_service(project)
114
- sync_dir = Path(project.path)
115
-
116
- logger.info(f"Sync starting project: {project.name}")
117
- await sync_service.sync(sync_dir, project_name=project.name)
118
- logger.info(f"Sync completed successfully for project: {project.name}")
119
-
120
- # After successful sync, remove the legacy directory
121
- try:
122
- logger.info(f"Removing legacy directory: {legacy_dir}")
123
- shutil.rmtree(legacy_dir)
124
- return True
125
- except Exception as e:
126
- logger.error(f"Error removing legacy directory: {e}")
127
- return False
128
-
129
-
130
71
  async def initialize_file_sync(
131
72
  app_config: BasicMemoryConfig,
132
73
  ):
@@ -186,16 +127,6 @@ async def initialize_file_sync(
186
127
  sync_status_tracker.fail_project_sync(project.name, str(e))
187
128
  # Continue with other projects even if one fails
188
129
 
189
- # Mark migration complete if it was in progress
190
- try:
191
- from basic_memory.services.migration_service import migration_manager
192
-
193
- if not migration_manager.is_ready: # pragma: no cover
194
- migration_manager.mark_completed("Migration completed with file sync")
195
- logger.info("Marked migration as completed after file sync")
196
- except Exception as e: # pragma: no cover
197
- logger.warning(f"Could not update migration status: {e}")
198
-
199
130
  # Then start the watch service in the background
200
131
  logger.info("Starting watch service for all projects")
201
132
  # run the watch service
@@ -229,13 +160,7 @@ async def initialize_app(
229
160
  # Reconcile projects from config.json with projects table
230
161
  await reconcile_projects_with_config(app_config)
231
162
 
232
- # Start background migration for legacy project data (non-blocking)
233
- from basic_memory.services.migration_service import migration_manager
234
-
235
- await migration_manager.start_background_migration(app_config)
236
-
237
163
  logger.info("App initialization completed (migration running in background if needed)")
238
- return migration_manager
239
164
 
240
165
 
241
166
  def ensure_initialization(app_config: BasicMemoryConfig) -> None:
@@ -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()
@@ -87,7 +103,7 @@ class ProjectService:
87
103
  resolved_path = os.path.abspath(os.path.expanduser(path))
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,7 +137,7 @@ 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
142
  # Then remove from database
127
143
  project = await self.repository.get_by_name(name)
@@ -143,7 +159,7 @@ 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
164
  # Then update database
149
165
  project = await self.repository.get_by_name(name)
@@ -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 = os.path.abspath(os.path.expanduser(new_path))
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
342
+ project = await self.repository.get_by_name(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,7 +367,7 @@ 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
373
  # Get project from database
@@ -323,10 +381,9 @@ class ProjectService:
323
381
  resolved_path = os.path.abspath(os.path.expanduser(updated_path))
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()
@@ -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(