basic-memory 0.12.3__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 (116) 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 +26 -7
  65. basic_memory/mcp/tools/recent_activity.py +11 -2
  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-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
  110. basic_memory-0.13.0.dist-info/RECORD +138 -0
  111. basic_memory/api/routers/project_info_router.py +0 -274
  112. basic_memory/mcp/main.py +0 -24
  113. basic_memory-0.12.3.dist-info/RECORD +0 -100
  114. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  115. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  116. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,671 @@
1
+ """Project management service for Basic Memory."""
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Dict, Optional, Sequence
8
+
9
+ from loguru import logger
10
+ from sqlalchemy import text
11
+
12
+ from basic_memory.config import config, app_config
13
+ from basic_memory.models import Project
14
+ from basic_memory.repository.project_repository import ProjectRepository
15
+ from basic_memory.schemas import (
16
+ ActivityMetrics,
17
+ ProjectInfoResponse,
18
+ ProjectStatistics,
19
+ SystemStatus,
20
+ )
21
+ from basic_memory.config import WATCH_STATUS_JSON
22
+ from basic_memory.utils import generate_permalink
23
+ from basic_memory.config import config_manager
24
+
25
+
26
+ class ProjectService:
27
+ """Service for managing Basic Memory projects."""
28
+
29
+ repository: ProjectRepository
30
+
31
+ def __init__(self, repository: ProjectRepository):
32
+ """Initialize the project service."""
33
+ super().__init__()
34
+ self.repository = repository
35
+
36
+ @property
37
+ def projects(self) -> Dict[str, str]:
38
+ """Get all configured projects.
39
+
40
+ Returns:
41
+ Dict mapping project names to their file paths
42
+ """
43
+ return config_manager.projects
44
+
45
+ @property
46
+ def default_project(self) -> str:
47
+ """Get the name of the default project.
48
+
49
+ Returns:
50
+ The name of the default project
51
+ """
52
+ return config_manager.default_project
53
+
54
+ @property
55
+ def current_project(self) -> str:
56
+ """Get the name of the currently active project.
57
+
58
+ Returns:
59
+ The name of the current project
60
+ """
61
+ return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
62
+
63
+ async def list_projects(self) -> Sequence[Project]:
64
+ return await self.repository.find_all()
65
+
66
+ async def get_project(self, name: str) -> Optional[Project]:
67
+ """Get the file path for a project by name."""
68
+ return await self.repository.get_by_name(name)
69
+
70
+ async def add_project(self, name: str, path: str, set_default: bool = False) -> None:
71
+ """Add a new project to the configuration and database.
72
+
73
+ Args:
74
+ name: The name of the project
75
+ path: The file path to the project directory
76
+ set_default: Whether to set this project as the default
77
+
78
+ Raises:
79
+ ValueError: If the project already exists
80
+ """
81
+ if not self.repository: # pragma: no cover
82
+ raise ValueError("Repository is required for add_project")
83
+
84
+ # Resolve to absolute path
85
+ resolved_path = os.path.abspath(os.path.expanduser(path))
86
+
87
+ # First add to config file (this will validate the project doesn't exist)
88
+ project_config = config_manager.add_project(name, resolved_path)
89
+
90
+ # Then add to database
91
+ project_data = {
92
+ "name": name,
93
+ "path": resolved_path,
94
+ "permalink": generate_permalink(project_config.name),
95
+ "is_active": True,
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
98
+ }
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")
106
+
107
+ logger.info(f"Project '{name}' added at {resolved_path}")
108
+
109
+ async def remove_project(self, name: str) -> None:
110
+ """Remove a project from configuration and database.
111
+
112
+ Args:
113
+ name: The name of the project to remove
114
+
115
+ Raises:
116
+ ValueError: If the project doesn't exist or is the default project
117
+ """
118
+ if not self.repository: # pragma: no cover
119
+ raise ValueError("Repository is required for remove_project")
120
+
121
+ # First remove from config (this will validate the project exists and is not default)
122
+ config_manager.remove_project(name)
123
+
124
+ # Then remove from database
125
+ project = await self.repository.get_by_name(name)
126
+ if project:
127
+ await self.repository.delete(project.id)
128
+
129
+ logger.info(f"Project '{name}' removed from configuration and database")
130
+
131
+ async def set_default_project(self, name: str) -> None:
132
+ """Set the default project in configuration and database.
133
+
134
+ Args:
135
+ name: The name of the project to set as default
136
+
137
+ Raises:
138
+ ValueError: If the project doesn't exist
139
+ """
140
+ if not self.repository: # pragma: no cover
141
+ raise ValueError("Repository is required for set_default_project")
142
+
143
+ # First update config file (this will validate the project exists)
144
+ config_manager.set_default_project(name)
145
+
146
+ # Then update database
147
+ project = await self.repository.get_by_name(name)
148
+ if project:
149
+ await self.repository.set_as_default(project.id)
150
+ else:
151
+ logger.error(f"Project '{name}' exists in config but not in database")
152
+
153
+ logger.info(f"Project '{name}' set as default in configuration and database")
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
+
196
+ async def synchronize_projects(self) -> None: # pragma: no cover
197
+ """Synchronize projects between database and configuration.
198
+
199
+ Ensures that all projects in the configuration file exist in the database
200
+ and vice versa. This should be called during initialization to reconcile
201
+ any differences between the two sources.
202
+ """
203
+ if not self.repository:
204
+ raise ValueError("Repository is required for synchronize_projects")
205
+
206
+ logger.info("Synchronizing projects between database and configuration")
207
+
208
+ # Get all projects from database
209
+ db_projects = await self.repository.get_active_projects()
210
+ db_projects_by_name = {p.name: p for p in db_projects}
211
+
212
+ # Get all projects from configuration and normalize names if needed
213
+ config_projects = config_manager.projects.copy()
214
+ updated_config = {}
215
+ config_updated = False
216
+
217
+ for name, path in config_projects.items():
218
+ # Generate normalized name (what the database expects)
219
+ normalized_name = generate_permalink(name)
220
+
221
+ if normalized_name != name:
222
+ logger.info(f"Normalizing project name in config: '{name}' -> '{normalized_name}'")
223
+ config_updated = True
224
+
225
+ updated_config[normalized_name] = path
226
+
227
+ # Update the configuration if any changes were made
228
+ if config_updated:
229
+ config_manager.config.projects = updated_config
230
+ config_manager.save_config(config_manager.config)
231
+ logger.info("Config updated with normalized project names")
232
+
233
+ # Use the normalized config for further processing
234
+ config_projects = updated_config
235
+
236
+ # Add projects that exist in config but not in DB
237
+ for name, path in config_projects.items():
238
+ if name not in db_projects_by_name:
239
+ logger.info(f"Adding project '{name}' to database")
240
+ project_data = {
241
+ "name": name,
242
+ "path": path,
243
+ "permalink": generate_permalink(name),
244
+ "is_active": True,
245
+ # Don't set is_default here - let the enforcement logic handle it
246
+ }
247
+ await self.repository.create(project_data)
248
+
249
+ # Add projects that exist in DB but not in config to config
250
+ for name, project in db_projects_by_name.items():
251
+ if name not in config_projects:
252
+ logger.info(f"Adding project '{name}' to configuration")
253
+ config_manager.add_project(name, project.path)
254
+
255
+ # Ensure database default project state is consistent
256
+ await self._ensure_single_default_project()
257
+
258
+ # Make sure default project is synchronized between config and database
259
+ db_default = await self.repository.get_default_project()
260
+ config_default = config_manager.default_project
261
+
262
+ if db_default and db_default.name != config_default:
263
+ # Update config to match DB default
264
+ logger.info(f"Updating default project in config to '{db_default.name}'")
265
+ config_manager.set_default_project(db_default.name)
266
+ elif not db_default and config_default:
267
+ # Update DB to match config default (if the project exists)
268
+ project = await self.repository.get_by_name(config_default)
269
+ if project:
270
+ logger.info(f"Updating default project in database to '{config_default}'")
271
+ await self.repository.set_as_default(project.id)
272
+
273
+ logger.info("Project synchronization complete")
274
+
275
+ async def update_project( # pragma: no cover
276
+ self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
277
+ ) -> None:
278
+ """Update project information in both config and database.
279
+
280
+ Args:
281
+ name: The name of the project to update
282
+ updated_path: Optional new path for the project
283
+ is_active: Optional flag to set project active status
284
+
285
+ Raises:
286
+ ValueError: If project doesn't exist or repository isn't initialized
287
+ """
288
+ if not self.repository:
289
+ raise ValueError("Repository is required for update_project")
290
+
291
+ # Validate project exists in config
292
+ if name not in config_manager.projects:
293
+ raise ValueError(f"Project '{name}' not found in configuration")
294
+
295
+ # Get project from database
296
+ project = await self.repository.get_by_name(name)
297
+ if not project:
298
+ logger.error(f"Project '{name}' exists in config but not in database")
299
+ return
300
+
301
+ # Update path if provided
302
+ if updated_path:
303
+ resolved_path = os.path.abspath(os.path.expanduser(updated_path))
304
+
305
+ # Update in config
306
+ projects = config_manager.config.projects.copy()
307
+ projects[name] = resolved_path
308
+ config_manager.config.projects = projects
309
+ config_manager.save_config(config_manager.config)
310
+
311
+ # Update in database
312
+ project.path = resolved_path
313
+ await self.repository.update(project.id, project)
314
+
315
+ logger.info(f"Updated path for project '{name}' to {resolved_path}")
316
+
317
+ # Update active status if provided
318
+ if is_active is not None:
319
+ project.is_active = is_active
320
+ await self.repository.update(project.id, project)
321
+ logger.info(f"Set active status for project '{name}' to {is_active}")
322
+
323
+ # If project was made inactive and it was the default, we need to pick a new default
324
+ if is_active is False and project.is_default:
325
+ # Find another active project
326
+ active_projects = await self.repository.get_active_projects()
327
+ if active_projects:
328
+ new_default = active_projects[0]
329
+ await self.repository.set_as_default(new_default.id)
330
+ config_manager.set_default_project(new_default.name)
331
+ logger.info(
332
+ f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
333
+ )
334
+
335
+ async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse:
336
+ """Get comprehensive information about the specified Basic Memory project.
337
+
338
+ Args:
339
+ project_name: Name of the project to get info for. If None, uses the current config project.
340
+
341
+ Returns:
342
+ Comprehensive project information and statistics
343
+ """
344
+ if not self.repository: # pragma: no cover
345
+ raise ValueError("Repository is required for get_project_info")
346
+
347
+ # Use specified project or fall back to config project
348
+ project_name = project_name or config.project
349
+ # Get project path from configuration
350
+ project_path = config_manager.projects.get(project_name)
351
+ if not project_path: # pragma: no cover
352
+ raise ValueError(f"Project '{project_name}' not found in configuration")
353
+
354
+ # Get project from database to get project_id
355
+ db_project = await self.repository.get_by_name(project_name)
356
+ if not db_project: # pragma: no cover
357
+ raise ValueError(f"Project '{project_name}' not found in database")
358
+
359
+ # Get statistics for the specified project
360
+ statistics = await self.get_statistics(db_project.id)
361
+
362
+ # Get activity metrics for the specified project
363
+ activity = await self.get_activity_metrics(db_project.id)
364
+
365
+ # Get system status
366
+ system = self.get_system_status()
367
+
368
+ # Get enhanced project information from database
369
+ db_projects = await self.repository.get_active_projects()
370
+ db_projects_by_name = {p.name: p for p in db_projects}
371
+
372
+ # Get default project info
373
+ default_project = config_manager.default_project
374
+
375
+ # Convert config projects to include database info
376
+ enhanced_projects = {}
377
+ for name, path in config_manager.projects.items():
378
+ db_project = db_projects_by_name.get(name)
379
+ enhanced_projects[name] = {
380
+ "path": path,
381
+ "active": db_project.is_active if db_project else True,
382
+ "id": db_project.id if db_project else None,
383
+ "is_default": (name == default_project),
384
+ "permalink": db_project.permalink if db_project else name.lower().replace(" ", "-"),
385
+ }
386
+
387
+ # Construct the response
388
+ return ProjectInfoResponse(
389
+ project_name=project_name,
390
+ project_path=project_path,
391
+ available_projects=enhanced_projects,
392
+ default_project=default_project,
393
+ statistics=statistics,
394
+ activity=activity,
395
+ system=system,
396
+ )
397
+
398
+ async def get_statistics(self, project_id: int) -> ProjectStatistics:
399
+ """Get statistics about the specified project.
400
+
401
+ Args:
402
+ project_id: ID of the project to get statistics for (required).
403
+ """
404
+ if not self.repository: # pragma: no cover
405
+ raise ValueError("Repository is required for get_statistics")
406
+
407
+ # Get basic counts
408
+ entity_count_result = await self.repository.execute_query(
409
+ text("SELECT COUNT(*) FROM entity WHERE project_id = :project_id"),
410
+ {"project_id": project_id},
411
+ )
412
+ total_entities = entity_count_result.scalar() or 0
413
+
414
+ observation_count_result = await self.repository.execute_query(
415
+ text(
416
+ "SELECT COUNT(*) FROM observation o JOIN entity e ON o.entity_id = e.id WHERE e.project_id = :project_id"
417
+ ),
418
+ {"project_id": project_id},
419
+ )
420
+ total_observations = observation_count_result.scalar() or 0
421
+
422
+ relation_count_result = await self.repository.execute_query(
423
+ text(
424
+ "SELECT COUNT(*) FROM relation r JOIN entity e ON r.from_id = e.id WHERE e.project_id = :project_id"
425
+ ),
426
+ {"project_id": project_id},
427
+ )
428
+ total_relations = relation_count_result.scalar() or 0
429
+
430
+ unresolved_count_result = await self.repository.execute_query(
431
+ text(
432
+ "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"
433
+ ),
434
+ {"project_id": project_id},
435
+ )
436
+ total_unresolved = unresolved_count_result.scalar() or 0
437
+
438
+ # Get entity counts by type
439
+ entity_types_result = await self.repository.execute_query(
440
+ text(
441
+ "SELECT entity_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY entity_type"
442
+ ),
443
+ {"project_id": project_id},
444
+ )
445
+ entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
446
+
447
+ # Get observation counts by category
448
+ category_result = await self.repository.execute_query(
449
+ text(
450
+ "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"
451
+ ),
452
+ {"project_id": project_id},
453
+ )
454
+ observation_categories = {row[0]: row[1] for row in category_result.fetchall()}
455
+
456
+ # Get relation counts by type
457
+ relation_types_result = await self.repository.execute_query(
458
+ text(
459
+ "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"
460
+ ),
461
+ {"project_id": project_id},
462
+ )
463
+ relation_types = {row[0]: row[1] for row in relation_types_result.fetchall()}
464
+
465
+ # Find most connected entities (most outgoing relations) - project filtered
466
+ connected_result = await self.repository.execute_query(
467
+ text("""
468
+ SELECT e.id, e.title, e.permalink, COUNT(r.id) AS relation_count, e.file_path
469
+ FROM entity e
470
+ JOIN relation r ON e.id = r.from_id
471
+ WHERE e.project_id = :project_id
472
+ GROUP BY e.id
473
+ ORDER BY relation_count DESC
474
+ LIMIT 10
475
+ """),
476
+ {"project_id": project_id},
477
+ )
478
+ most_connected = [
479
+ {
480
+ "id": row[0],
481
+ "title": row[1],
482
+ "permalink": row[2],
483
+ "relation_count": row[3],
484
+ "file_path": row[4],
485
+ }
486
+ for row in connected_result.fetchall()
487
+ ]
488
+
489
+ # Count isolated entities (no relations) - project filtered
490
+ isolated_result = await self.repository.execute_query(
491
+ text("""
492
+ SELECT COUNT(e.id)
493
+ FROM entity e
494
+ LEFT JOIN relation r1 ON e.id = r1.from_id
495
+ LEFT JOIN relation r2 ON e.id = r2.to_id
496
+ WHERE e.project_id = :project_id AND r1.id IS NULL AND r2.id IS NULL
497
+ """),
498
+ {"project_id": project_id},
499
+ )
500
+ isolated_count = isolated_result.scalar() or 0
501
+
502
+ return ProjectStatistics(
503
+ total_entities=total_entities,
504
+ total_observations=total_observations,
505
+ total_relations=total_relations,
506
+ total_unresolved_relations=total_unresolved,
507
+ entity_types=entity_types,
508
+ observation_categories=observation_categories,
509
+ relation_types=relation_types,
510
+ most_connected_entities=most_connected,
511
+ isolated_entities=isolated_count,
512
+ )
513
+
514
+ async def get_activity_metrics(self, project_id: int) -> ActivityMetrics:
515
+ """Get activity metrics for the specified project.
516
+
517
+ Args:
518
+ project_id: ID of the project to get activity metrics for (required).
519
+ """
520
+ if not self.repository: # pragma: no cover
521
+ raise ValueError("Repository is required for get_activity_metrics")
522
+
523
+ # Get recently created entities (project filtered)
524
+ created_result = await self.repository.execute_query(
525
+ text("""
526
+ SELECT id, title, permalink, entity_type, created_at, file_path
527
+ FROM entity
528
+ WHERE project_id = :project_id
529
+ ORDER BY created_at DESC
530
+ LIMIT 10
531
+ """),
532
+ {"project_id": project_id},
533
+ )
534
+ recently_created = [
535
+ {
536
+ "id": row[0],
537
+ "title": row[1],
538
+ "permalink": row[2],
539
+ "entity_type": row[3],
540
+ "created_at": row[4],
541
+ "file_path": row[5],
542
+ }
543
+ for row in created_result.fetchall()
544
+ ]
545
+
546
+ # Get recently updated entities (project filtered)
547
+ updated_result = await self.repository.execute_query(
548
+ text("""
549
+ SELECT id, title, permalink, entity_type, updated_at, file_path
550
+ FROM entity
551
+ WHERE project_id = :project_id
552
+ ORDER BY updated_at DESC
553
+ LIMIT 10
554
+ """),
555
+ {"project_id": project_id},
556
+ )
557
+ recently_updated = [
558
+ {
559
+ "id": row[0],
560
+ "title": row[1],
561
+ "permalink": row[2],
562
+ "entity_type": row[3],
563
+ "updated_at": row[4],
564
+ "file_path": row[5],
565
+ }
566
+ for row in updated_result.fetchall()
567
+ ]
568
+
569
+ # Get monthly growth over the last 6 months
570
+ # Calculate the start of 6 months ago
571
+ now = datetime.now()
572
+ six_months_ago = datetime(
573
+ now.year - (1 if now.month <= 6 else 0), ((now.month - 6) % 12) or 12, 1
574
+ )
575
+
576
+ # Query for monthly entity creation (project filtered)
577
+ entity_growth_result = await self.repository.execute_query(
578
+ text("""
579
+ SELECT
580
+ strftime('%Y-%m', created_at) AS month,
581
+ COUNT(*) AS count
582
+ FROM entity
583
+ WHERE created_at >= :six_months_ago AND project_id = :project_id
584
+ GROUP BY month
585
+ ORDER BY month
586
+ """),
587
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
588
+ )
589
+ entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
590
+
591
+ # Query for monthly observation creation (project filtered)
592
+ observation_growth_result = await self.repository.execute_query(
593
+ text("""
594
+ SELECT
595
+ strftime('%Y-%m', entity.created_at) AS month,
596
+ COUNT(*) AS count
597
+ FROM observation
598
+ INNER JOIN entity ON observation.entity_id = entity.id
599
+ WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
600
+ GROUP BY month
601
+ ORDER BY month
602
+ """),
603
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
604
+ )
605
+ observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
606
+
607
+ # Query for monthly relation creation (project filtered)
608
+ relation_growth_result = await self.repository.execute_query(
609
+ text("""
610
+ SELECT
611
+ strftime('%Y-%m', entity.created_at) AS month,
612
+ COUNT(*) AS count
613
+ FROM relation
614
+ INNER JOIN entity ON relation.from_id = entity.id
615
+ WHERE entity.created_at >= :six_months_ago AND entity.project_id = :project_id
616
+ GROUP BY month
617
+ ORDER BY month
618
+ """),
619
+ {"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
620
+ )
621
+ relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
622
+
623
+ # Combine all monthly growth data
624
+ monthly_growth = {}
625
+ for month in set(
626
+ list(entity_growth.keys())
627
+ + list(observation_growth.keys())
628
+ + list(relation_growth.keys())
629
+ ):
630
+ monthly_growth[month] = {
631
+ "entities": entity_growth.get(month, 0),
632
+ "observations": observation_growth.get(month, 0),
633
+ "relations": relation_growth.get(month, 0),
634
+ "total": (
635
+ entity_growth.get(month, 0)
636
+ + observation_growth.get(month, 0)
637
+ + relation_growth.get(month, 0)
638
+ ),
639
+ }
640
+
641
+ return ActivityMetrics(
642
+ recently_created=recently_created,
643
+ recently_updated=recently_updated,
644
+ monthly_growth=monthly_growth,
645
+ )
646
+
647
+ def get_system_status(self) -> SystemStatus:
648
+ """Get system status information."""
649
+ import basic_memory
650
+
651
+ # Get database information
652
+ db_path = app_config.database_path
653
+ db_size = db_path.stat().st_size if db_path.exists() else 0
654
+ db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
655
+
656
+ # Get watch service status if available
657
+ watch_status = None
658
+ watch_status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
659
+ if watch_status_path.exists():
660
+ try:
661
+ watch_status = json.loads(watch_status_path.read_text(encoding="utf-8"))
662
+ except Exception: # pragma: no cover
663
+ pass
664
+
665
+ return SystemStatus(
666
+ version=basic_memory.__version__,
667
+ database_path=str(db_path),
668
+ database_size=db_size_readable,
669
+ watch_status=watch_status,
670
+ timestamp=datetime.now(),
671
+ )