basic-memory 0.13.0b3__py3-none-any.whl → 0.13.0b5__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 (41) hide show
  1. basic_memory/__init__.py +1 -7
  2. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  3. basic_memory/api/routers/knowledge_router.py +13 -0
  4. basic_memory/api/routers/memory_router.py +3 -4
  5. basic_memory/api/routers/project_router.py +9 -9
  6. basic_memory/api/routers/prompt_router.py +2 -2
  7. basic_memory/cli/commands/project.py +2 -2
  8. basic_memory/cli/commands/status.py +1 -1
  9. basic_memory/cli/commands/sync.py +1 -1
  10. basic_memory/mcp/prompts/__init__.py +2 -0
  11. basic_memory/mcp/prompts/sync_status.py +116 -0
  12. basic_memory/mcp/server.py +6 -6
  13. basic_memory/mcp/tools/__init__.py +4 -0
  14. basic_memory/mcp/tools/build_context.py +32 -7
  15. basic_memory/mcp/tools/canvas.py +2 -1
  16. basic_memory/mcp/tools/delete_note.py +159 -4
  17. basic_memory/mcp/tools/edit_note.py +17 -11
  18. basic_memory/mcp/tools/move_note.py +252 -40
  19. basic_memory/mcp/tools/project_management.py +35 -3
  20. basic_memory/mcp/tools/read_note.py +9 -2
  21. basic_memory/mcp/tools/search.py +180 -8
  22. basic_memory/mcp/tools/sync_status.py +254 -0
  23. basic_memory/mcp/tools/utils.py +47 -0
  24. basic_memory/mcp/tools/view_note.py +66 -0
  25. basic_memory/mcp/tools/write_note.py +13 -2
  26. basic_memory/models/project.py +1 -3
  27. basic_memory/repository/search_repository.py +99 -26
  28. basic_memory/schemas/base.py +33 -5
  29. basic_memory/schemas/memory.py +58 -1
  30. basic_memory/services/entity_service.py +4 -4
  31. basic_memory/services/initialization.py +32 -5
  32. basic_memory/services/link_resolver.py +20 -5
  33. basic_memory/services/migration_service.py +168 -0
  34. basic_memory/services/project_service.py +157 -56
  35. basic_memory/services/sync_status_service.py +181 -0
  36. basic_memory/sync/sync_service.py +55 -2
  37. {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/METADATA +2 -2
  38. {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/RECORD +41 -35
  39. {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/WHEEL +0 -0
  40. {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/entry_points.txt +0 -0
  41. {basic_memory-0.13.0b3.dist-info → basic_memory-0.13.0b5.dist-info}/licenses/LICENSE +0 -0
@@ -67,12 +67,13 @@ class ProjectService:
67
67
  """Get the file path for a project by name."""
68
68
  return await self.repository.get_by_name(name)
69
69
 
70
- async def add_project(self, name: str, path: str) -> None:
70
+ async def add_project(self, name: str, path: str, set_default: bool = False) -> None:
71
71
  """Add a new project to the configuration and database.
72
72
 
73
73
  Args:
74
74
  name: The name of the project
75
75
  path: The file path to the project directory
76
+ set_default: Whether to set this project as the default
76
77
 
77
78
  Raises:
78
79
  ValueError: If the project already exists
@@ -92,9 +93,16 @@ class ProjectService:
92
93
  "path": resolved_path,
93
94
  "permalink": generate_permalink(project_config.name),
94
95
  "is_active": True,
95
- "is_default": False,
96
+ # Don't set is_default=False to avoid UNIQUE constraint issues
97
+ # Let it default to NULL, only set to True when explicitly making default
96
98
  }
97
- await self.repository.create(project_data)
99
+ created_project = await self.repository.create(project_data)
100
+
101
+ # If this should be the default project, ensure only one default exists
102
+ if set_default:
103
+ await self.repository.set_as_default(created_project.id)
104
+ config_manager.set_default_project(name)
105
+ logger.info(f"Project '{name}' set as default")
98
106
 
99
107
  logger.info(f"Project '{name}' added at {resolved_path}")
100
108
 
@@ -144,6 +152,47 @@ class ProjectService:
144
152
 
145
153
  logger.info(f"Project '{name}' set as default in configuration and database")
146
154
 
155
+ async def _ensure_single_default_project(self) -> None:
156
+ """Ensure only one project has is_default=True.
157
+
158
+ This method validates the database state and fixes any issues where
159
+ multiple projects might have is_default=True or no project is marked as default.
160
+ """
161
+ if not self.repository:
162
+ raise ValueError(
163
+ "Repository is required for _ensure_single_default_project"
164
+ ) # pragma: no cover
165
+
166
+ # Get all projects with is_default=True
167
+ db_projects = await self.repository.find_all()
168
+ default_projects = [p for p in db_projects if p.is_default is True]
169
+
170
+ if len(default_projects) > 1: # pragma: no cover
171
+ # Multiple defaults found - fix by keeping the first one and clearing others
172
+ # This is defensive code that should rarely execute due to business logic enforcement
173
+ logger.warning( # pragma: no cover
174
+ f"Found {len(default_projects)} projects with is_default=True, fixing..."
175
+ )
176
+ keep_default = default_projects[0] # pragma: no cover
177
+
178
+ # Clear all defaults first, then set only the first one as default
179
+ await self.repository.set_as_default(keep_default.id) # pragma: no cover
180
+
181
+ logger.info(
182
+ f"Fixed default project conflicts, kept '{keep_default.name}' as default"
183
+ ) # pragma: no cover
184
+
185
+ elif len(default_projects) == 0: # pragma: no cover
186
+ # No default project - set the config default as default
187
+ # This is defensive code for edge cases where no default exists
188
+ config_default = config_manager.default_project # pragma: no cover
189
+ config_project = await self.repository.get_by_name(config_default) # pragma: no cover
190
+ if config_project: # pragma: no cover
191
+ await self.repository.set_as_default(config_project.id) # pragma: no cover
192
+ logger.info(
193
+ f"Set '{config_default}' as default project (was missing)"
194
+ ) # pragma: no cover
195
+
147
196
  async def synchronize_projects(self) -> None: # pragma: no cover
148
197
  """Synchronize projects between database and configuration.
149
198
 
@@ -172,7 +221,7 @@ class ProjectService:
172
221
  "path": path,
173
222
  "permalink": name.lower().replace(" ", "-"),
174
223
  "is_active": True,
175
- "is_default": (name == config_manager.default_project),
224
+ # Don't set is_default here - let the enforcement logic handle it
176
225
  }
177
226
  await self.repository.create(project_data)
178
227
 
@@ -182,19 +231,23 @@ class ProjectService:
182
231
  logger.info(f"Adding project '{name}' to configuration")
183
232
  config_manager.add_project(name, project.path)
184
233
 
185
- # Make sure default project is synchronized
186
- db_default = next((p for p in db_projects if p.is_default), None)
234
+ # Ensure database default project state is consistent
235
+ await self._ensure_single_default_project()
236
+
237
+ # Make sure default project is synchronized between config and database
238
+ db_default = await self.repository.get_default_project()
187
239
  config_default = config_manager.default_project
188
240
 
189
241
  if db_default and db_default.name != config_default:
190
242
  # Update config to match DB default
191
243
  logger.info(f"Updating default project in config to '{db_default.name}'")
192
244
  config_manager.set_default_project(db_default.name)
193
- elif not db_default and config_default in db_projects_by_name:
194
- # Update DB to match config default
195
- logger.info(f"Updating default project in database to '{config_default}'")
196
- project = db_projects_by_name[config_default]
197
- await self.repository.set_as_default(project.id)
245
+ elif not db_default and config_default:
246
+ # Update DB to match config default (if the project exists)
247
+ project = await self.repository.get_by_name(config_default)
248
+ if project:
249
+ logger.info(f"Updating default project in database to '{config_default}'")
250
+ await self.repository.set_as_default(project.id)
198
251
 
199
252
  logger.info("Project synchronization complete")
200
253
 
@@ -258,8 +311,11 @@ class ProjectService:
258
311
  f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
259
312
  )
260
313
 
261
- async def get_project_info(self) -> ProjectInfoResponse:
262
- """Get comprehensive information about the current Basic Memory project.
314
+ async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse:
315
+ """Get comprehensive information about the specified Basic Memory project.
316
+
317
+ Args:
318
+ project_name: Name of the project to get info for. If None, uses the current config project.
263
319
 
264
320
  Returns:
265
321
  Comprehensive project information and statistics
@@ -267,19 +323,27 @@ class ProjectService:
267
323
  if not self.repository: # pragma: no cover
268
324
  raise ValueError("Repository is required for get_project_info")
269
325
 
270
- # Get statistics
271
- statistics = await self.get_statistics()
326
+ # Use specified project or fall back to config project
327
+ project_name = project_name or config.project
328
+ # Get project path from configuration
329
+ project_path = config_manager.projects.get(project_name)
330
+ if not project_path: # pragma: no cover
331
+ raise ValueError(f"Project '{project_name}' not found in configuration")
332
+
333
+ # Get project from database to get project_id
334
+ db_project = await self.repository.get_by_name(project_name)
335
+ if not db_project: # pragma: no cover
336
+ raise ValueError(f"Project '{project_name}' not found in database")
337
+
338
+ # Get statistics for the specified project
339
+ statistics = await self.get_statistics(db_project.id)
272
340
 
273
- # Get activity metrics
274
- activity = await self.get_activity_metrics()
341
+ # Get activity metrics for the specified project
342
+ activity = await self.get_activity_metrics(db_project.id)
275
343
 
276
344
  # Get system status
277
345
  system = self.get_system_status()
278
346
 
279
- # Get current project information from config
280
- project_name = config.project
281
- project_path = str(config.home)
282
-
283
347
  # Get enhanced project information from database
284
348
  db_projects = await self.repository.get_active_projects()
285
349
  db_projects_by_name = {p.name: p for p in db_projects}
@@ -310,60 +374,85 @@ class ProjectService:
310
374
  system=system,
311
375
  )
312
376
 
313
- async def get_statistics(self) -> ProjectStatistics:
314
- """Get statistics about the current project."""
377
+ async def get_statistics(self, project_id: int) -> ProjectStatistics:
378
+ """Get statistics about the specified project.
379
+
380
+ Args:
381
+ project_id: ID of the project to get statistics for (required).
382
+ """
315
383
  if not self.repository: # pragma: no cover
316
384
  raise ValueError("Repository is required for get_statistics")
317
385
 
318
386
  # Get basic counts
319
387
  entity_count_result = await self.repository.execute_query(
320
- text("SELECT COUNT(*) FROM entity")
388
+ text("SELECT COUNT(*) FROM entity WHERE project_id = :project_id"),
389
+ {"project_id": project_id},
321
390
  )
322
391
  total_entities = entity_count_result.scalar() or 0
323
392
 
324
393
  observation_count_result = await self.repository.execute_query(
325
- text("SELECT COUNT(*) FROM observation")
394
+ text(
395
+ "SELECT COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id"
396
+ ),
397
+ {"project_id": project_id},
326
398
  )
327
399
  total_observations = observation_count_result.scalar() or 0
328
400
 
329
401
  relation_count_result = await self.repository.execute_query(
330
- text("SELECT COUNT(*) FROM relation")
402
+ text(
403
+ "SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id"
404
+ ),
405
+ {"project_id": project_id},
331
406
  )
332
407
  total_relations = relation_count_result.scalar() or 0
333
408
 
334
409
  unresolved_count_result = await self.repository.execute_query(
335
- text("SELECT COUNT(*) FROM relation WHERE to_id IS NULL")
410
+ text(
411
+ "SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE r.to_id IS NULL AND e.project_id = :project_id"
412
+ ),
413
+ {"project_id": project_id},
336
414
  )
337
415
  total_unresolved = unresolved_count_result.scalar() or 0
338
416
 
339
417
  # Get entity counts by type
340
418
  entity_types_result = await self.repository.execute_query(
341
- text("SELECT entity_type, COUNT(*) FROM entity GROUP BY entity_type")
419
+ text(
420
+ "SELECT entity_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY entity_type"
421
+ ),
422
+ {"project_id": project_id},
342
423
  )
343
424
  entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
344
425
 
345
426
  # Get observation counts by category
346
427
  category_result = await self.repository.execute_query(
347
- text("SELECT category, COUNT(*) FROM observation GROUP BY category")
428
+ text(
429
+ "SELECT o.category, COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id GROUP BY o.category"
430
+ ),
431
+ {"project_id": project_id},
348
432
  )
349
433
  observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
350
434
 
351
435
  # Get relation counts by type
352
436
  relation_types_result = await self.repository.execute_query(
353
- text("SELECT relation_type, COUNT(*) FROM relation GROUP BY relation_type")
437
+ text(
438
+ "SELECT r.relation_type, COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id GROUP BY r.relation_type"
439
+ ),
440
+ {"project_id": project_id},
354
441
  )
355
442
  relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
356
443
 
357
- # Find most connected entities (most outgoing relations)
444
+ # Find most connected entities (most outgoing relations) - project filtered
358
445
  connected_result = await self.repository.execute_query(
359
446
  text("""
360
- SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, file_path
447
+ SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, e.file_path
361
448
  FROM entity e
362
449
  JOIN relation r ON e.id = r.from_id
450
+ WHERE e.project_id = :project_id
363
451
  GROUP BY e.id
364
452
  ORDER BY relation_count DESC
365
453
  LIMIT 10
366
- """)
454
+ """),
455
+ {"project_id": project_id},
367
456
  )
368
457
  most_connected = [
369
458
  {
@@ -376,15 +465,16 @@ class ProjectService:
376
465
  for row in connected_result.fetchall()
377
466
  ]
378
467
 
379
- # Count isolated entities (no relations)
468
+ # Count isolated entities (no relations) - project filtered
380
469
  isolated_result = await self.repository.execute_query(
381
470
  text("""
382
471
  SELECT COUNT(e.id)
383
472
  FROM entity e
384
473
  LEFT JOIN relation r1 ON e.id = r1.from_id
385
474
  LEFT JOIN relation r2 ON e.id = r2.to_id
386
- WHERE r1.id IS NULL AND r2.id IS NULL
387
- """)
475
+ WHERE e.project_id = :project_id AND r1.id IS NULL AND r2.id IS NULL
476
+ """),
477
+ {"project_id": project_id},
388
478
  )
389
479
  isolated_count = isolated_result.scalar() or 0
390
480
 
@@ -400,19 +490,25 @@ class ProjectService:
400
490
  isolated_entities=isolated_count,
401
491
  )
402
492
 
403
- async def get_activity_metrics(self) -> ActivityMetrics:
404
- """Get activity metrics for the current project."""
493
+ async def get_activity_metrics(self, project_id: int) -> ActivityMetrics:
494
+ """Get activity metrics for the specified project.
495
+
496
+ Args:
497
+ project_id: ID of the project to get activity metrics for (required).
498
+ """
405
499
  if not self.repository: # pragma: no cover
406
500
  raise ValueError("Repository is required for get_activity_metrics")
407
501
 
408
- # Get recently created entities
502
+ # Get recently created entities (project filtered)
409
503
  created_result = await self.repository.execute_query(
410
504
  text("""
411
505
  SELECT id, title, permalink, entity_type, created_at, file_path
412
506
  FROM entity
507
+ WHERE project_id = :project_id
413
508
  ORDER BY created_at DESC
414
509
  LIMIT 10
415
- """)
510
+ """),
511
+ {"project_id": project_id},
416
512
  )
417
513
  recently_created = [
418
514
  {
@@ -426,14 +522,16 @@ class ProjectService:
426
522
  for row in created_result.fetchall()
427
523
  ]
428
524
 
429
- # Get recently updated entities
525
+ # Get recently updated entities (project filtered)
430
526
  updated_result = await self.repository.execute_query(
431
527
  text("""
432
528
  SELECT id, title, permalink, entity_type, updated_at, file_path
433
529
  FROM entity
530
+ WHERE project_id = :project_id
434
531
  ORDER BY updated_at DESC
435
532
  LIMIT 10
436
- """)
533
+ """),
534
+ {"project_id": project_id},
437
535
  )
438
536
  recently_updated = [
439
537
  {
@@ -454,47 +552,50 @@ class ProjectService:
454
552
  now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
455
553
  )
456
554
 
457
- # Query for monthly entity creation
555
+ # Query for monthly entity creation (project filtered)
458
556
  entity_growth_result = await self.repository.execute_query(
459
- text(f"""
557
+ text("""
460
558
  SELECT
461
559
  strftime('%Y-%m', created_at) AS month,
462
560
  COUNT(*) AS count
463
561
  FROM entity
464
- WHERE created_at >= '{six_months_ago.isoformat()}'
562
+ WHERE created_at >= :six_months_ago AND project_id = :project_id
465
563
  GROUP BY month
466
564
  ORDER BY month
467
- """)
565
+ """),
566
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
468
567
  )
469
568
  entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
470
569
 
471
- # Query for monthly observation creation
570
+ # Query for monthly observation creation (project filtered)
472
571
  observation_growth_result = await self.repository.execute_query(
473
- text(f"""
572
+ text("""
474
573
  SELECT
475
- strftime('%Y-%m', created_at) AS month,
574
+ strftime('%Y-%m', entity.created_at) AS month,
476
575
  COUNT(*) AS count
477
576
  FROM observation
478
577
  INNER JOIN entity ON observation.entity_id = entity.id
479
- WHERE entity.created_at >= '{six_months_ago.isoformat()}'
578
+ WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
480
579
  GROUP BY month
481
580
  ORDER BY month
482
- """)
581
+ """),
582
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
483
583
  )
484
584
  observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
485
585
 
486
- # Query for monthly relation creation
586
+ # Query for monthly relation creation (project filtered)
487
587
  relation_growth_result = await self.repository.execute_query(
488
- text(f"""
588
+ text("""
489
589
  SELECT
490
- strftime('%Y-%m', created_at) AS month,
590
+ strftime('%Y-%m', entity.created_at) AS month,
491
591
  COUNT(*) AS count
492
592
  FROM relation
493
593
  INNER JOIN entity ON relation.from_id = entity.id
494
- WHERE entity.created_at >= '{six_months_ago.isoformat()}'
594
+ WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
495
595
  GROUP BY month
496
596
  ORDER BY month
497
- """)
597
+ """),
598
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
498
599
  )
499
600
  relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
500
601
 
@@ -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()