basic-memory 0.7.0__py3-none-any.whl → 0.16.1__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 (150) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +64 -18
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -23
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +411 -62
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +383 -51
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -3,10 +3,14 @@
3
3
  from pathlib import Path
4
4
  from typing import List, Optional, Sequence, Union
5
5
 
6
+ from loguru import logger
7
+ from sqlalchemy import select
8
+ from sqlalchemy.exc import IntegrityError
6
9
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
7
10
  from sqlalchemy.orm import selectinload
8
11
  from sqlalchemy.orm.interfaces import LoaderOption
9
12
 
13
+ from basic_memory import db
10
14
  from basic_memory.models.knowledge import Entity, Observation, Relation
11
15
  from basic_memory.repository.repository import Repository
12
16
 
@@ -18,9 +22,14 @@ class EntityRepository(Repository[Entity]):
18
22
  to strings before passing to repository methods.
19
23
  """
20
24
 
21
- def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
22
- """Initialize with session maker."""
23
- super().__init__(session_maker, Entity)
25
+ def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
26
+ """Initialize with session maker and project_id filter.
27
+
28
+ Args:
29
+ session_maker: SQLAlchemy session maker
30
+ project_id: Project ID to filter all operations by
31
+ """
32
+ super().__init__(session_maker, Entity, project_id=project_id)
24
33
 
25
34
  async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
26
35
  """Get entity by permalink.
@@ -31,14 +40,15 @@ class EntityRepository(Repository[Entity]):
31
40
  query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
32
41
  return await self.find_one(query)
33
42
 
34
- async def get_by_title(self, title: str) -> Optional[Entity]:
43
+ async def get_by_title(self, title: str) -> Sequence[Entity]:
35
44
  """Get entity by title.
36
45
 
37
46
  Args:
38
47
  title: Title of the entity to find
39
48
  """
40
49
  query = self.select().where(Entity.title == title).options(*self.get_load_options())
41
- return await self.find_one(query)
50
+ result = await self.execute_query(query)
51
+ return list(result.scalars().all())
42
52
 
43
53
  async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]:
44
54
  """Get entity by file_path.
@@ -48,18 +58,35 @@ class EntityRepository(Repository[Entity]):
48
58
  """
49
59
  query = (
50
60
  self.select()
51
- .where(Entity.file_path == str(file_path))
61
+ .where(Entity.file_path == Path(file_path).as_posix())
52
62
  .options(*self.get_load_options())
53
63
  )
54
64
  return await self.find_one(query)
55
65
 
66
+ async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
67
+ """Find entities with the given checksum.
68
+
69
+ Used for move detection - finds entities that may have been moved to a new path.
70
+ Multiple entities may have the same checksum if files were copied.
71
+
72
+ Args:
73
+ checksum: File content checksum to search for
74
+
75
+ Returns:
76
+ Sequence of entities with matching checksum (may be empty)
77
+ """
78
+ query = self.select().where(Entity.checksum == checksum)
79
+ # Don't load relationships for move detection - we only need file_path and checksum
80
+ result = await self.execute_query(query, use_query_options=False)
81
+ return list(result.scalars().all())
82
+
56
83
  async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
57
84
  """Delete entity with the provided file_path.
58
85
 
59
86
  Args:
60
87
  file_path: Path to the entity file (will be converted to string internally)
61
88
  """
62
- return await self.delete_by_fields(file_path=str(file_path))
89
+ return await self.delete_by_fields(file_path=Path(file_path).as_posix())
63
90
 
64
91
  def get_load_options(self) -> List[LoaderOption]:
65
92
  """Get SQLAlchemy loader options for eager loading relationships."""
@@ -90,3 +117,198 @@ class EntityRepository(Repository[Entity]):
90
117
 
91
118
  result = await self.execute_query(query)
92
119
  return list(result.scalars().all())
120
+
121
+ async def upsert_entity(self, entity: Entity) -> Entity:
122
+ """Insert or update entity using simple try/catch with database-level conflict resolution.
123
+
124
+ Handles file_path race conditions by checking for existing entity on IntegrityError.
125
+ For permalink conflicts, generates a unique permalink with numeric suffix.
126
+
127
+ Args:
128
+ entity: The entity to insert or update
129
+
130
+ Returns:
131
+ The inserted or updated entity
132
+ """
133
+ async with db.scoped_session(self.session_maker) as session:
134
+ # Set project_id if applicable and not already set
135
+ self._set_project_id_if_needed(entity)
136
+
137
+ # Try simple insert first
138
+ try:
139
+ session.add(entity)
140
+ await session.flush()
141
+
142
+ # Return with relationships loaded
143
+ query = (
144
+ self.select()
145
+ .where(Entity.file_path == entity.file_path)
146
+ .options(*self.get_load_options())
147
+ )
148
+ result = await session.execute(query)
149
+ found = result.scalar_one_or_none()
150
+ if not found: # pragma: no cover
151
+ raise RuntimeError(
152
+ f"Failed to retrieve entity after insert: {entity.file_path}"
153
+ )
154
+ return found
155
+
156
+ except IntegrityError as e:
157
+ # Check if this is a FOREIGN KEY constraint failure
158
+ error_str = str(e)
159
+ if "FOREIGN KEY constraint failed" in error_str:
160
+ # Import locally to avoid circular dependency (repository -> services -> repository)
161
+ from basic_memory.services.exceptions import SyncFatalError
162
+
163
+ # Project doesn't exist in database - this is a fatal sync error
164
+ raise SyncFatalError(
165
+ f"Cannot sync file '{entity.file_path}': "
166
+ f"project_id={entity.project_id} does not exist in database. "
167
+ f"The project may have been deleted. This sync will be terminated."
168
+ ) from e
169
+
170
+ await session.rollback()
171
+
172
+ # Re-query after rollback to get a fresh, attached entity
173
+ existing_result = await session.execute(
174
+ select(Entity)
175
+ .where(
176
+ Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
177
+ )
178
+ .options(*self.get_load_options())
179
+ )
180
+ existing_entity = existing_result.scalar_one_or_none()
181
+
182
+ if existing_entity:
183
+ # File path conflict - update the existing entity
184
+ logger.debug(
185
+ f"Resolving file_path conflict for {entity.file_path}, "
186
+ f"entity_id={existing_entity.id}, observations={len(entity.observations)}"
187
+ )
188
+ # Use merge to avoid session state conflicts
189
+ # Set the ID to update existing entity
190
+ entity.id = existing_entity.id
191
+
192
+ # Ensure observations reference the correct entity_id
193
+ for obs in entity.observations:
194
+ obs.entity_id = existing_entity.id
195
+ # Clear any existing ID to force INSERT as new observation
196
+ obs.id = None
197
+
198
+ # Merge the entity which will update the existing one
199
+ merged_entity = await session.merge(entity)
200
+
201
+ await session.commit()
202
+
203
+ # Re-query to get proper relationships loaded
204
+ final_result = await session.execute(
205
+ select(Entity)
206
+ .where(Entity.id == merged_entity.id)
207
+ .options(*self.get_load_options())
208
+ )
209
+ return final_result.scalar_one()
210
+
211
+ else:
212
+ # No file_path conflict - must be permalink conflict
213
+ # Generate unique permalink and retry
214
+ entity = await self._handle_permalink_conflict(entity, session)
215
+ return entity
216
+
217
+ async def get_all_file_paths(self) -> List[str]:
218
+ """Get all file paths for this project - optimized for deletion detection.
219
+
220
+ Returns only file_path strings without loading entities or relationships.
221
+ Used by streaming sync to detect deleted files efficiently.
222
+
223
+ Returns:
224
+ List of file_path strings for all entities in the project
225
+ """
226
+ query = select(Entity.file_path)
227
+ query = self._add_project_filter(query)
228
+
229
+ result = await self.execute_query(query, use_query_options=False)
230
+ return list(result.scalars().all())
231
+
232
+ async def get_distinct_directories(self) -> List[str]:
233
+ """Extract unique directory paths from file_path column.
234
+
235
+ Optimized method for getting directory structure without loading full entities
236
+ or relationships. Returns a sorted list of unique directory paths.
237
+
238
+ Returns:
239
+ List of unique directory paths (e.g., ["notes", "notes/meetings", "specs"])
240
+ """
241
+ # Query only file_path column, no entity objects or relationships
242
+ query = select(Entity.file_path).distinct()
243
+ query = self._add_project_filter(query)
244
+
245
+ # Execute with use_query_options=False to skip eager loading
246
+ result = await self.execute_query(query, use_query_options=False)
247
+ file_paths = [row for row in result.scalars().all()]
248
+
249
+ # Parse file paths to extract unique directories
250
+ directories = set()
251
+ for file_path in file_paths:
252
+ parts = [p for p in file_path.split("/") if p]
253
+ # Add all parent directories (exclude filename which is the last part)
254
+ for i in range(len(parts) - 1):
255
+ dir_path = "/".join(parts[: i + 1])
256
+ directories.add(dir_path)
257
+
258
+ return sorted(directories)
259
+
260
+ async def find_by_directory_prefix(self, directory_prefix: str) -> Sequence[Entity]:
261
+ """Find entities whose file_path starts with the given directory prefix.
262
+
263
+ Optimized method for listing directory contents without loading all entities.
264
+ Uses SQL LIKE pattern matching to filter entities by directory path.
265
+
266
+ Args:
267
+ directory_prefix: Directory path prefix (e.g., "docs", "docs/guides")
268
+ Empty string returns all entities (root directory)
269
+
270
+ Returns:
271
+ Sequence of entities in the specified directory and subdirectories
272
+ """
273
+ # Build SQL LIKE pattern
274
+ if directory_prefix == "" or directory_prefix == "/":
275
+ # Root directory - return all entities
276
+ return await self.find_all()
277
+
278
+ # Remove leading/trailing slashes for consistency
279
+ directory_prefix = directory_prefix.strip("/")
280
+
281
+ # Query entities with file_path starting with prefix
282
+ # Pattern matches "prefix/" to ensure we get files IN the directory,
283
+ # not just files whose names start with the prefix
284
+ pattern = f"{directory_prefix}/%"
285
+
286
+ query = self.select().where(Entity.file_path.like(pattern))
287
+
288
+ # Skip eager loading - we only need basic entity fields for directory trees
289
+ result = await self.execute_query(query, use_query_options=False)
290
+ return list(result.scalars().all())
291
+
292
+ async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity:
293
+ """Handle permalink conflicts by generating a unique permalink."""
294
+ base_permalink = entity.permalink
295
+ suffix = 1
296
+
297
+ # Find a unique permalink
298
+ while True:
299
+ test_permalink = f"{base_permalink}-{suffix}"
300
+ existing = await session.execute(
301
+ select(Entity).where(
302
+ Entity.permalink == test_permalink, Entity.project_id == entity.project_id
303
+ )
304
+ )
305
+ if existing.scalar_one_or_none() is None:
306
+ # Found unique permalink
307
+ entity.permalink = test_permalink
308
+ break
309
+ suffix += 1
310
+
311
+ # Insert with unique permalink
312
+ session.add(entity)
313
+ await session.flush()
314
+ return entity
@@ -1,6 +1,6 @@
1
1
  """Repository for managing Observation objects."""
2
2
 
3
- from typing import Sequence
3
+ from typing import Dict, List, Sequence
4
4
 
5
5
  from sqlalchemy import select
6
6
  from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -12,8 +12,14 @@ from basic_memory.repository.repository import Repository
12
12
  class ObservationRepository(Repository[Observation]):
13
13
  """Repository for Observation model with memory-specific operations."""
14
14
 
15
- def __init__(self, session_maker: async_sessionmaker):
16
- super().__init__(session_maker, Observation)
15
+ def __init__(self, session_maker: async_sessionmaker, project_id: int):
16
+ """Initialize with session maker and project_id filter.
17
+
18
+ Args:
19
+ session_maker: SQLAlchemy session maker
20
+ project_id: Project ID to filter all operations by
21
+ """
22
+ super().__init__(session_maker, Observation, project_id=project_id)
17
23
 
18
24
  async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
19
25
  """Find all observations for a specific entity."""
@@ -38,3 +44,29 @@ class ObservationRepository(Repository[Observation]):
38
44
  query = select(Observation.category).distinct()
39
45
  result = await self.execute_query(query, use_query_options=False)
40
46
  return result.scalars().all()
47
+
48
+ async def find_by_entities(self, entity_ids: List[int]) -> Dict[int, List[Observation]]:
49
+ """Find all observations for multiple entities in a single query.
50
+
51
+ Args:
52
+ entity_ids: List of entity IDs to fetch observations for
53
+
54
+ Returns:
55
+ Dictionary mapping entity_id to list of observations
56
+ """
57
+ if not entity_ids: # pragma: no cover
58
+ return {}
59
+
60
+ # Query observations for all entities in the list
61
+ query = select(Observation).filter(Observation.entity_id.in_(entity_ids))
62
+ result = await self.execute_query(query)
63
+ observations = result.scalars().all()
64
+
65
+ # Group observations by entity_id
66
+ observations_by_entity = {}
67
+ for obs in observations:
68
+ if obs.entity_id not in observations_by_entity:
69
+ observations_by_entity[obs.entity_id] = []
70
+ observations_by_entity[obs.entity_id].append(obs)
71
+
72
+ return observations_by_entity
@@ -0,0 +1,10 @@
1
+ from basic_memory.repository.repository import Repository
2
+ from basic_memory.models.project import Project
3
+
4
+
5
+ class ProjectInfoRepository(Repository):
6
+ """Repository for statistics queries."""
7
+
8
+ def __init__(self, session_maker):
9
+ # Initialize with Project model as a reference
10
+ super().__init__(session_maker, Project)
@@ -0,0 +1,103 @@
1
+ """Repository for managing projects in Basic Memory."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Sequence, Union
5
+
6
+ from sqlalchemy import text
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from basic_memory import db
10
+ from basic_memory.models.project import Project
11
+ from basic_memory.repository.repository import Repository
12
+
13
+
14
+ class ProjectRepository(Repository[Project]):
15
+ """Repository for Project model.
16
+
17
+ Projects represent collections of knowledge entities grouped together.
18
+ Each entity, observation, and relation belongs to a specific project.
19
+ """
20
+
21
+ def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
22
+ """Initialize with session maker."""
23
+ super().__init__(session_maker, Project)
24
+
25
+ async def get_by_name(self, name: str) -> Optional[Project]:
26
+ """Get project by name.
27
+
28
+ Args:
29
+ name: Unique name of the project
30
+ """
31
+ query = self.select().where(Project.name == name)
32
+ return await self.find_one(query)
33
+
34
+ async def get_by_permalink(self, permalink: str) -> Optional[Project]:
35
+ """Get project by permalink.
36
+
37
+ Args:
38
+ permalink: URL-friendly identifier for the project
39
+ """
40
+ query = self.select().where(Project.permalink == permalink)
41
+ return await self.find_one(query)
42
+
43
+ async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]:
44
+ """Get project by filesystem path.
45
+
46
+ Args:
47
+ path: Path to the project directory (will be converted to string internally)
48
+ """
49
+ query = self.select().where(Project.path == Path(path).as_posix())
50
+ return await self.find_one(query)
51
+
52
+ async def get_default_project(self) -> Optional[Project]:
53
+ """Get the default project (the one marked as is_default=True)."""
54
+ query = self.select().where(Project.is_default.is_not(None))
55
+ return await self.find_one(query)
56
+
57
+ async def get_active_projects(self) -> Sequence[Project]:
58
+ """Get all active projects."""
59
+ query = self.select().where(Project.is_active == True) # noqa: E712
60
+ result = await self.execute_query(query)
61
+ return list(result.scalars().all())
62
+
63
+ async def set_as_default(self, project_id: int) -> Optional[Project]:
64
+ """Set a project as the default and unset previous default.
65
+
66
+ Args:
67
+ project_id: ID of the project to set as default
68
+
69
+ Returns:
70
+ The updated project if found, None otherwise
71
+ """
72
+ async with db.scoped_session(self.session_maker) as session:
73
+ # First, clear the default flag for all projects using direct SQL
74
+ await session.execute(
75
+ text("UPDATE project SET is_default = NULL WHERE is_default IS NOT NULL")
76
+ )
77
+ await session.flush()
78
+
79
+ # Set the new default project
80
+ target_project = await self.select_by_id(session, project_id)
81
+ if target_project:
82
+ target_project.is_default = True
83
+ await session.flush()
84
+ return target_project
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
@@ -16,8 +16,14 @@ from basic_memory.repository.repository import Repository
16
16
  class RelationRepository(Repository[Relation]):
17
17
  """Repository for Relation model with memory-specific operations."""
18
18
 
19
- def __init__(self, session_maker: async_sessionmaker):
20
- super().__init__(session_maker, Relation)
19
+ def __init__(self, session_maker: async_sessionmaker, project_id: int):
20
+ """Initialize with session maker and project_id filter.
21
+
22
+ Args:
23
+ session_maker: SQLAlchemy session maker
24
+ project_id: Project ID to filter all operations by
25
+ """
26
+ super().__init__(session_maker, Relation, project_id=project_id)
21
27
 
22
28
  async def find_relation(
23
29
  self, from_permalink: str, to_permalink: str, relation_type: str
@@ -67,5 +73,18 @@ class RelationRepository(Repository[Relation]):
67
73
  result = await self.execute_query(query)
68
74
  return result.scalars().all()
69
75
 
76
+ async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
77
+ """Find unresolved relations for a specific entity.
78
+
79
+ Args:
80
+ entity_id: The entity whose unresolved outgoing relations to find.
81
+
82
+ Returns:
83
+ List of unresolved relations where this entity is the source.
84
+ """
85
+ query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
86
+ result = await self.execute_query(query)
87
+ return result.scalars().all()
88
+
70
89
  def get_load_options(self) -> List[LoaderOption]:
71
90
  return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]