basic-memory 0.12.2__py3-none-any.whl → 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,6 @@
1
1
  """Service for search operations."""
2
2
 
3
+ import ast
3
4
  from datetime import datetime
4
5
  from typing import List, Optional, Set
5
6
 
@@ -66,7 +67,7 @@ class SearchService:
66
67
  logger.debug("no criteria passed to query")
67
68
  return []
68
69
 
69
- logger.debug(f"Searching with query: {query}")
70
+ logger.trace(f"Searching with query: {query}")
70
71
 
71
72
  after_date = (
72
73
  (
@@ -85,7 +86,7 @@ class SearchService:
85
86
  permalink_match=query.permalink_match,
86
87
  title=query.title,
87
88
  types=query.types,
88
- entity_types=query.entity_types,
89
+ search_item_types=query.entity_types,
89
90
  after_date=after_date,
90
91
  limit=limit,
91
92
  offset=offset,
@@ -117,6 +118,38 @@ class SearchService:
117
118
 
118
119
  return variants
119
120
 
121
+ def _extract_entity_tags(self, entity: Entity) -> List[str]:
122
+ """Extract tags from entity metadata for search indexing.
123
+
124
+ Handles multiple tag formats:
125
+ - List format: ["tag1", "tag2"]
126
+ - String format: "['tag1', 'tag2']" or "[tag1, tag2]"
127
+ - Empty: [] or "[]"
128
+
129
+ Returns a list of tag strings for search indexing.
130
+ """
131
+ if not entity.entity_metadata or "tags" not in entity.entity_metadata:
132
+ return []
133
+
134
+ tags = entity.entity_metadata["tags"]
135
+
136
+ # Handle list format (preferred)
137
+ if isinstance(tags, list):
138
+ return [str(tag) for tag in tags if tag]
139
+
140
+ # Handle string format (legacy)
141
+ if isinstance(tags, str):
142
+ try:
143
+ # Parse string representation of list
144
+ parsed_tags = ast.literal_eval(tags)
145
+ if isinstance(parsed_tags, list):
146
+ return [str(tag) for tag in parsed_tags if tag]
147
+ except (ValueError, SyntaxError):
148
+ # If parsing fails, treat as single tag
149
+ return [tags] if tags.strip() else []
150
+
151
+ return [] # pragma: no cover
152
+
120
153
  async def index_entity(
121
154
  self,
122
155
  entity: Entity,
@@ -156,6 +189,7 @@ class SearchService:
156
189
  },
157
190
  created_at=entity.created_at,
158
191
  updated_at=entity.updated_at,
192
+ project_id=entity.project_id,
159
193
  )
160
194
  )
161
195
 
@@ -169,16 +203,20 @@ class SearchService:
169
203
  1. Entities
170
204
  - permalink: direct from entity (e.g., "specs/search")
171
205
  - file_path: physical file location
206
+ - project_id: project context for isolation
172
207
 
173
208
  2. Observations
174
209
  - permalink: entity permalink + /observations/id (e.g., "specs/search/observations/123")
175
210
  - file_path: parent entity's file (where observation is defined)
211
+ - project_id: inherited from parent entity
176
212
 
177
213
  3. Relations (only index outgoing relations defined in this file)
178
214
  - permalink: from_entity/relation_type/to_entity (e.g., "specs/search/implements/features/search-ui")
179
215
  - file_path: source entity's file (where relation is defined)
216
+ - project_id: inherited from source entity
180
217
 
181
218
  Each type gets its own row in the search index with appropriate metadata.
219
+ The project_id is automatically added by the repository when indexing.
182
220
  """
183
221
 
184
222
  content_stems = []
@@ -196,6 +234,11 @@ class SearchService:
196
234
 
197
235
  content_stems.extend(self._generate_variants(entity.file_path))
198
236
 
237
+ # Add entity tags from frontmatter to search content
238
+ entity_tags = self._extract_entity_tags(entity)
239
+ if entity_tags:
240
+ content_stems.extend(entity_tags)
241
+
199
242
  entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
200
243
 
201
244
  # Index entity
@@ -214,6 +257,7 @@ class SearchService:
214
257
  },
215
258
  created_at=entity.created_at,
216
259
  updated_at=entity.updated_at,
260
+ project_id=entity.project_id,
217
261
  )
218
262
  )
219
263
 
@@ -239,6 +283,7 @@ class SearchService:
239
283
  },
240
284
  created_at=entity.created_at,
241
285
  updated_at=entity.updated_at,
286
+ project_id=entity.project_id,
242
287
  )
243
288
  )
244
289
 
@@ -268,6 +313,7 @@ class SearchService:
268
313
  relation_type=rel.relation_type,
269
314
  created_at=entity.created_at,
270
315
  updated_at=entity.updated_at,
316
+ project_id=entity.project_id,
271
317
  )
272
318
  )
273
319
 
@@ -278,3 +324,32 @@ class SearchService:
278
324
  async def delete_by_entity_id(self, entity_id: int):
279
325
  """Delete an item from the search index."""
280
326
  await self.repository.delete_by_entity_id(entity_id)
327
+
328
+ async def handle_delete(self, entity: Entity):
329
+ """Handle complete entity deletion from search index including observations and relations.
330
+
331
+ This replicates the logic from sync_service.handle_delete() to properly clean up
332
+ all search index entries for an entity and its related data.
333
+ """
334
+ logger.debug(
335
+ f"Cleaning up search index for entity_id={entity.id}, file_path={entity.file_path}, "
336
+ f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
337
+ )
338
+
339
+ # Clean up search index - same logic as sync_service.handle_delete()
340
+ permalinks = (
341
+ [entity.permalink]
342
+ + [o.permalink for o in entity.observations]
343
+ + [r.permalink for r in entity.outgoing_relations]
344
+ )
345
+
346
+ logger.debug(
347
+ f"Deleting search index entries for entity_id={entity.id}, "
348
+ f"index_entries={len(permalinks)}"
349
+ )
350
+
351
+ for permalink in permalinks:
352
+ if permalink:
353
+ await self.delete_by_permalink(permalink)
354
+ else:
355
+ await self.delete_by_entity_id(entity.id)
@@ -0,0 +1,181 @@
1
+ """Simple sync status tracking service."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Dict, Optional
6
+
7
+
8
+ class SyncStatus(Enum):
9
+ """Status of sync operations."""
10
+
11
+ IDLE = "idle"
12
+ SCANNING = "scanning"
13
+ SYNCING = "syncing"
14
+ COMPLETED = "completed"
15
+ FAILED = "failed"
16
+ WATCHING = "watching"
17
+
18
+
19
+ @dataclass
20
+ class ProjectSyncStatus:
21
+ """Sync status for a single project."""
22
+
23
+ project_name: str
24
+ status: SyncStatus
25
+ message: str = ""
26
+ files_total: int = 0
27
+ files_processed: int = 0
28
+ error: Optional[str] = None
29
+
30
+
31
+ class SyncStatusTracker:
32
+ """Global tracker for all sync operations."""
33
+
34
+ def __init__(self):
35
+ self._project_statuses: Dict[str, ProjectSyncStatus] = {}
36
+ self._global_status: SyncStatus = SyncStatus.IDLE
37
+
38
+ def start_project_sync(self, project_name: str, files_total: int = 0) -> None:
39
+ """Start tracking sync for a project."""
40
+ self._project_statuses[project_name] = ProjectSyncStatus(
41
+ project_name=project_name,
42
+ status=SyncStatus.SCANNING,
43
+ message="Scanning files",
44
+ files_total=files_total,
45
+ files_processed=0,
46
+ )
47
+ self._update_global_status()
48
+
49
+ def update_project_progress( # pragma: no cover
50
+ self,
51
+ project_name: str,
52
+ status: SyncStatus,
53
+ message: str = "",
54
+ files_processed: int = 0,
55
+ files_total: Optional[int] = None,
56
+ ) -> None:
57
+ """Update progress for a project."""
58
+ if project_name not in self._project_statuses: # pragma: no cover
59
+ return
60
+
61
+ project_status = self._project_statuses[project_name]
62
+ project_status.status = status
63
+ project_status.message = message
64
+ project_status.files_processed = files_processed
65
+
66
+ if files_total is not None:
67
+ project_status.files_total = files_total
68
+
69
+ self._update_global_status()
70
+
71
+ def complete_project_sync(self, project_name: str) -> None:
72
+ """Mark project sync as completed."""
73
+ if project_name in self._project_statuses:
74
+ self._project_statuses[project_name].status = SyncStatus.COMPLETED
75
+ self._project_statuses[project_name].message = "Sync completed"
76
+ self._update_global_status()
77
+
78
+ def fail_project_sync(self, project_name: str, error: str) -> None:
79
+ """Mark project sync as failed."""
80
+ if project_name in self._project_statuses:
81
+ self._project_statuses[project_name].status = SyncStatus.FAILED
82
+ self._project_statuses[project_name].error = error
83
+ self._update_global_status()
84
+
85
+ def start_project_watch(self, project_name: str) -> None:
86
+ """Mark project as watching for changes (steady state after sync)."""
87
+ if project_name in self._project_statuses:
88
+ self._project_statuses[project_name].status = SyncStatus.WATCHING
89
+ self._project_statuses[project_name].message = "Watching for changes"
90
+ self._update_global_status()
91
+ else:
92
+ # Create new status if project isn't tracked yet
93
+ self._project_statuses[project_name] = ProjectSyncStatus(
94
+ project_name=project_name,
95
+ status=SyncStatus.WATCHING,
96
+ message="Watching for changes",
97
+ files_total=0,
98
+ files_processed=0,
99
+ )
100
+ self._update_global_status()
101
+
102
+ def _update_global_status(self) -> None:
103
+ """Update global status based on project statuses."""
104
+ if not self._project_statuses: # pragma: no cover
105
+ self._global_status = SyncStatus.IDLE
106
+ return
107
+
108
+ statuses = [p.status for p in self._project_statuses.values()]
109
+
110
+ if any(s == SyncStatus.FAILED for s in statuses):
111
+ self._global_status = SyncStatus.FAILED
112
+ elif any(s in (SyncStatus.SCANNING, SyncStatus.SYNCING) for s in statuses):
113
+ self._global_status = SyncStatus.SYNCING
114
+ elif all(s in (SyncStatus.COMPLETED, SyncStatus.WATCHING) for s in statuses):
115
+ self._global_status = SyncStatus.COMPLETED
116
+ else:
117
+ self._global_status = SyncStatus.SYNCING
118
+
119
+ @property
120
+ def global_status(self) -> SyncStatus:
121
+ """Get overall sync status."""
122
+ return self._global_status
123
+
124
+ @property
125
+ def is_syncing(self) -> bool:
126
+ """Check if any sync operation is in progress."""
127
+ return self._global_status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
128
+
129
+ @property
130
+ def is_ready(self) -> bool: # pragma: no cover
131
+ """Check if system is ready (no sync in progress)."""
132
+ return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
133
+
134
+ def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
135
+ """Get status for a specific project."""
136
+ return self._project_statuses.get(project_name)
137
+
138
+ def get_all_projects(self) -> Dict[str, ProjectSyncStatus]:
139
+ """Get all project statuses."""
140
+ return self._project_statuses.copy()
141
+
142
+ def get_summary(self) -> str: # pragma: no cover
143
+ """Get a user-friendly summary of sync status."""
144
+ if self._global_status == SyncStatus.IDLE:
145
+ return "✅ System ready"
146
+ elif self._global_status == SyncStatus.COMPLETED:
147
+ return "✅ All projects synced successfully"
148
+ elif self._global_status == SyncStatus.FAILED:
149
+ failed_projects = [
150
+ p.project_name
151
+ for p in self._project_statuses.values()
152
+ if p.status == SyncStatus.FAILED
153
+ ]
154
+ return f"❌ Sync failed for: {', '.join(failed_projects)}"
155
+ else:
156
+ active_projects = [
157
+ p.project_name
158
+ for p in self._project_statuses.values()
159
+ if p.status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
160
+ ]
161
+ total_files = sum(p.files_total for p in self._project_statuses.values())
162
+ processed_files = sum(p.files_processed for p in self._project_statuses.values())
163
+
164
+ if total_files > 0:
165
+ progress_pct = (processed_files / total_files) * 100
166
+ return f"🔄 Syncing {len(active_projects)} projects ({processed_files}/{total_files} files, {progress_pct:.0f}%)"
167
+ else:
168
+ return f"🔄 Syncing {len(active_projects)} projects"
169
+
170
+ def clear_completed(self) -> None:
171
+ """Remove completed project statuses to clean up memory."""
172
+ self._project_statuses = {
173
+ name: status
174
+ for name, status in self._project_statuses.items()
175
+ if status.status != SyncStatus.COMPLETED
176
+ }
177
+ self._update_global_status()
178
+
179
+
180
+ # Global sync status tracker instance
181
+ sync_status_tracker = SyncStatusTracker()
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+
3
+ from loguru import logger
4
+
5
+ from basic_memory.config import config as project_config
6
+ from basic_memory.sync import SyncService, WatchService
7
+
8
+
9
+ async def sync_and_watch(
10
+ sync_service: SyncService, watch_service: WatchService
11
+ ): # pragma: no cover
12
+ """Run sync and watch service."""
13
+
14
+ logger.info(f"Starting watch service to sync file changes in dir: {project_config.home}")
15
+ # full sync
16
+ await sync_service.sync(project_config.home)
17
+
18
+ # watch changes
19
+ await watch_service.run()
20
+
21
+
22
+ async def create_background_sync_task(
23
+ sync_service: SyncService, watch_service: WatchService
24
+ ): # pragma: no cover
25
+ return asyncio.create_task(sync_and_watch(sync_service, watch_service))
@@ -10,13 +10,14 @@ from typing import Dict, Optional, Set, Tuple
10
10
  from loguru import logger
11
11
  from sqlalchemy.exc import IntegrityError
12
12
 
13
- from basic_memory.config import ProjectConfig
13
+ from basic_memory.config import BasicMemoryConfig
14
14
  from basic_memory.file_utils import has_frontmatter
15
15
  from basic_memory.markdown import EntityParser
16
16
  from basic_memory.models import Entity
17
17
  from basic_memory.repository import EntityRepository, RelationRepository
18
18
  from basic_memory.services import EntityService, FileService
19
19
  from basic_memory.services.search_service import SearchService
20
+ from basic_memory.services.sync_status_service import sync_status_tracker, SyncStatus
20
21
 
21
22
 
22
23
  @dataclass
@@ -64,7 +65,7 @@ class SyncService:
64
65
 
65
66
  def __init__(
66
67
  self,
67
- config: ProjectConfig,
68
+ app_config: BasicMemoryConfig,
68
69
  entity_service: EntityService,
69
70
  entity_parser: EntityParser,
70
71
  entity_repository: EntityRepository,
@@ -72,7 +73,7 @@ class SyncService:
72
73
  search_service: SearchService,
73
74
  file_service: FileService,
74
75
  ):
75
- self.config = config
76
+ self.app_config = app_config
76
77
  self.entity_service = entity_service
77
78
  self.entity_parser = entity_parser
78
79
  self.entity_repository = entity_repository
@@ -80,23 +81,38 @@ class SyncService:
80
81
  self.search_service = search_service
81
82
  self.file_service = file_service
82
83
 
83
- async def sync(self, directory: Path) -> SyncReport:
84
+ async def sync(self, directory: Path, project_name: Optional[str] = None) -> SyncReport:
84
85
  """Sync all files with database."""
85
86
 
86
87
  start_time = time.time()
87
88
  logger.info(f"Sync operation started for directory: {directory}")
88
89
 
90
+ # Start tracking sync for this project if project name provided
91
+ if project_name:
92
+ sync_status_tracker.start_project_sync(project_name)
93
+
89
94
  # initial paths from db to sync
90
95
  # path -> checksum
91
96
  report = await self.scan(directory)
92
97
 
93
- # Initialize progress tracking if requested
98
+ # Update progress with file counts
99
+ if project_name:
100
+ sync_status_tracker.update_project_progress(
101
+ project_name=project_name,
102
+ status=SyncStatus.SYNCING,
103
+ message="Processing file changes",
104
+ files_total=report.total,
105
+ files_processed=0,
106
+ )
107
+
94
108
  # order of sync matters to resolve relations effectively
95
109
  logger.info(
96
110
  f"Sync changes detected: new_files={len(report.new)}, modified_files={len(report.modified)}, "
97
111
  + f"deleted_files={len(report.deleted)}, moved_files={len(report.moves)}"
98
112
  )
99
113
 
114
+ files_processed = 0
115
+
100
116
  # sync moves first
101
117
  for old_path, new_path in report.moves.items():
102
118
  # in the case where a file has been deleted and replaced by another file
@@ -109,19 +125,56 @@ class SyncService:
109
125
  else:
110
126
  await self.handle_move(old_path, new_path)
111
127
 
128
+ files_processed += 1
129
+ if project_name:
130
+ sync_status_tracker.update_project_progress( # pragma: no cover
131
+ project_name=project_name,
132
+ status=SyncStatus.SYNCING,
133
+ message="Processing moves",
134
+ files_processed=files_processed,
135
+ )
136
+
112
137
  # deleted next
113
138
  for path in report.deleted:
114
139
  await self.handle_delete(path)
140
+ files_processed += 1
141
+ if project_name:
142
+ sync_status_tracker.update_project_progress( # pragma: no cover
143
+ project_name=project_name,
144
+ status=SyncStatus.SYNCING,
145
+ message="Processing deletions",
146
+ files_processed=files_processed,
147
+ )
115
148
 
116
149
  # then new and modified
117
150
  for path in report.new:
118
151
  await self.sync_file(path, new=True)
152
+ files_processed += 1
153
+ if project_name:
154
+ sync_status_tracker.update_project_progress(
155
+ project_name=project_name,
156
+ status=SyncStatus.SYNCING,
157
+ message="Processing new files",
158
+ files_processed=files_processed,
159
+ )
119
160
 
120
161
  for path in report.modified:
121
162
  await self.sync_file(path, new=False)
163
+ files_processed += 1
164
+ if project_name:
165
+ sync_status_tracker.update_project_progress( # pragma: no cover
166
+ project_name=project_name,
167
+ status=SyncStatus.SYNCING,
168
+ message="Processing modified files",
169
+ files_processed=files_processed,
170
+ )
122
171
 
123
172
  await self.resolve_relations()
124
173
 
174
+ # Mark sync as completed
175
+ if project_name:
176
+ sync_status_tracker.complete_project_sync(project_name)
177
+
125
178
  duration_ms = int((time.time() - start_time) * 1000)
126
179
  logger.info(
127
180
  f"Sync operation completed: directory={directory}, total_changes={report.total}, duration_ms={duration_ms}"
@@ -133,7 +186,7 @@ class SyncService:
133
186
  """Scan directory for changes compared to database state."""
134
187
 
135
188
  db_paths = await self.get_db_file_state()
136
- logger.debug(f"Found {len(db_paths)} db paths")
189
+ logger.info(f"Scanning directory {directory}. Found {len(db_paths)} db paths")
137
190
 
138
191
  # Track potentially moved files by checksum
139
192
  scan_result = await self.scan_directory(directory)
@@ -173,6 +226,7 @@ class SyncService:
173
226
  # deleted
174
227
  else:
175
228
  report.deleted.add(db_path)
229
+ logger.info(f"Completed scan for directory {directory}, found {report.total} changes.")
176
230
  return report
177
231
 
178
232
  async def get_db_file_state(self) -> Dict[str, str]:
@@ -218,7 +272,7 @@ class SyncService:
218
272
  return entity, checksum
219
273
 
220
274
  except Exception as e: # pragma: no cover
221
- logger.exception("Failed to sync file", path=path, error=str(e))
275
+ logger.error(f"Failed to sync file: path={path}, error={str(e)}")
222
276
  return None, None
223
277
 
224
278
  async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
@@ -310,18 +364,43 @@ class SyncService:
310
364
  content_type = self.file_service.content_type(path)
311
365
 
312
366
  file_path = Path(path)
313
- entity = await self.entity_repository.add(
314
- Entity(
315
- entity_type="file",
316
- file_path=path,
317
- checksum=checksum,
318
- title=file_path.name,
319
- created_at=created,
320
- updated_at=modified,
321
- content_type=content_type,
367
+ try:
368
+ entity = await self.entity_repository.add(
369
+ Entity(
370
+ entity_type="file",
371
+ file_path=path,
372
+ checksum=checksum,
373
+ title=file_path.name,
374
+ created_at=created,
375
+ updated_at=modified,
376
+ content_type=content_type,
377
+ )
322
378
  )
323
- )
324
- return entity, checksum
379
+ return entity, checksum
380
+ except IntegrityError as e:
381
+ # Handle race condition where entity was created by another process
382
+ if "UNIQUE constraint failed: entity.file_path" in str(e):
383
+ logger.info(
384
+ f"Entity already exists for file_path={path}, updating instead of creating"
385
+ )
386
+ # Treat as update instead of create
387
+ entity = await self.entity_repository.get_by_file_path(path)
388
+ if entity is None: # pragma: no cover
389
+ logger.error(f"Entity not found after constraint violation, path={path}")
390
+ raise ValueError(f"Entity not found after constraint violation: {path}")
391
+
392
+ updated = await self.entity_repository.update(
393
+ entity.id, {"file_path": path, "checksum": checksum}
394
+ )
395
+
396
+ if updated is None: # pragma: no cover
397
+ logger.error(f"Failed to update entity, entity_id={entity.id}, path={path}")
398
+ raise ValueError(f"Failed to update entity with ID {entity.id}")
399
+
400
+ return updated, checksum
401
+ else:
402
+ # Re-raise if it's a different integrity error
403
+ raise
325
404
  else:
326
405
  entity = await self.entity_repository.get_by_file_path(path)
327
406
  if entity is None: # pragma: no cover
@@ -378,7 +457,9 @@ class SyncService:
378
457
  updates = {"file_path": new_path}
379
458
 
380
459
  # If configured, also update permalink to match new path
381
- if self.config.update_permalinks_on_move:
460
+ if self.app_config.update_permalinks_on_move and self.file_service.is_markdown(
461
+ new_path
462
+ ):
382
463
  # generate new permalink value
383
464
  new_permalink = await self.entity_service.resolve_permalink(new_path)
384
465
 
@@ -426,7 +507,7 @@ class SyncService:
426
507
  logger.info("Resolving forward references", count=len(unresolved_relations))
427
508
 
428
509
  for relation in unresolved_relations:
429
- logger.debug(
510
+ logger.trace(
430
511
  "Attempting to resolve relation "
431
512
  f"relation_id={relation.id} "
432
513
  f"from_id={relation.from_id} "
@@ -494,7 +575,7 @@ class SyncService:
494
575
  result.files[rel_path] = checksum
495
576
  result.checksums[checksum] = rel_path
496
577
 
497
- logger.debug("Found file", path=rel_path, checksum=checksum)
578
+ logger.trace(f"Found file, path={rel_path}, checksum={checksum}")
498
579
 
499
580
  duration_ms = int((time.time() - start_time) * 1000)
500
581
  logger.debug(