basic-memory 0.16.1__py3-none-any.whl → 0.17.4__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,130 @@
1
+ """V2 routes for memory:// URI operations.
2
+
3
+ This router uses external_id UUIDs for stable, API-friendly routing.
4
+ V1 uses string-based project names which are less efficient and less stable.
5
+ """
6
+
7
+ from typing import Annotated, Optional
8
+
9
+ from fastapi import APIRouter, Query, Path
10
+ from loguru import logger
11
+
12
+ from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
13
+ from basic_memory.schemas.base import TimeFrame, parse_timeframe
14
+ from basic_memory.schemas.memory import (
15
+ GraphContext,
16
+ normalize_memory_url,
17
+ )
18
+ from basic_memory.schemas.search import SearchItemType
19
+ from basic_memory.api.routers.utils import to_graph_context
20
+
21
+ # Note: No prefix here - it's added during registration as /v2/{project_id}/memory
22
+ router = APIRouter(tags=["memory"])
23
+
24
+
25
+ @router.get("/memory/recent", response_model=GraphContext)
26
+ async def recent(
27
+ context_service: ContextServiceV2ExternalDep,
28
+ entity_repository: EntityRepositoryV2ExternalDep,
29
+ project_id: str = Path(..., description="Project external UUID"),
30
+ type: Annotated[list[SearchItemType] | None, Query()] = None,
31
+ depth: int = 1,
32
+ timeframe: TimeFrame = "7d",
33
+ page: int = 1,
34
+ page_size: int = 10,
35
+ max_related: int = 10,
36
+ ) -> GraphContext:
37
+ """Get recent activity context for a project.
38
+
39
+ Args:
40
+ project_id: Project external UUID from URL path
41
+ context_service: Context service scoped to project
42
+ entity_repository: Entity repository scoped to project
43
+ type: Types of items to include (entities, relations, observations)
44
+ depth: How many levels of related entities to include
45
+ timeframe: Time window for recent activity (e.g., "7d", "1 week")
46
+ page: Page number for pagination
47
+ page_size: Number of items per page
48
+ max_related: Maximum related entities to include per item
49
+
50
+ Returns:
51
+ GraphContext with recent activity and related entities
52
+ """
53
+ # return all types by default
54
+ types = (
55
+ [SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
56
+ if not type
57
+ else type
58
+ )
59
+
60
+ logger.debug(
61
+ f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
62
+ )
63
+ # Parse timeframe
64
+ since = parse_timeframe(timeframe)
65
+ limit = page_size
66
+ offset = (page - 1) * page_size
67
+
68
+ # Build context
69
+ context = await context_service.build_context(
70
+ types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
71
+ )
72
+ recent_context = await to_graph_context(
73
+ context, entity_repository=entity_repository, page=page, page_size=page_size
74
+ )
75
+ logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
76
+ return recent_context
77
+
78
+
79
+ # get_memory_context needs to be declared last so other paths can match
80
+
81
+
82
+ @router.get("/memory/{uri:path}", response_model=GraphContext)
83
+ async def get_memory_context(
84
+ context_service: ContextServiceV2ExternalDep,
85
+ entity_repository: EntityRepositoryV2ExternalDep,
86
+ uri: str,
87
+ project_id: str = Path(..., description="Project external UUID"),
88
+ depth: int = 1,
89
+ timeframe: Optional[TimeFrame] = None,
90
+ page: int = 1,
91
+ page_size: int = 10,
92
+ max_related: int = 10,
93
+ ) -> GraphContext:
94
+ """Get rich context from memory:// URI.
95
+
96
+ V2 supports both legacy path-based URIs and new ID-based URIs:
97
+ - Legacy: memory://path/to/note
98
+ - ID-based: memory://id/123 or memory://123
99
+
100
+ Args:
101
+ project_id: Project external UUID from URL path
102
+ context_service: Context service scoped to project
103
+ entity_repository: Entity repository scoped to project
104
+ uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
105
+ depth: How many levels of related entities to include
106
+ timeframe: Optional time window for filtering related content
107
+ page: Page number for pagination
108
+ page_size: Number of items per page
109
+ max_related: Maximum related entities to include
110
+
111
+ Returns:
112
+ GraphContext with the entity and its related context
113
+ """
114
+ logger.debug(
115
+ f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
116
+ )
117
+ memory_url = normalize_memory_url(uri)
118
+
119
+ # Parse timeframe
120
+ since = parse_timeframe(timeframe) if timeframe else None
121
+ limit = page_size
122
+ offset = (page - 1) * page_size
123
+
124
+ # Build context
125
+ context = await context_service.build_context(
126
+ memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
127
+ )
128
+ return await to_graph_context(
129
+ context, entity_repository=entity_repository, page=page, page_size=page_size
130
+ )
@@ -0,0 +1,359 @@
1
+ """V2 Project Router - External ID-based project management operations.
2
+
3
+ This router provides external_id (UUID) based CRUD operations for projects,
4
+ using stable string UUIDs that never change (unlike integer IDs or names).
5
+
6
+ Key improvements:
7
+ - Stable external UUIDs that won't change with renames or database migrations
8
+ - Better API ergonomics with consistent string identifiers
9
+ - Direct database lookups via unique indexed column
10
+ - Consistent with v2 entity operations
11
+ """
12
+
13
+ import os
14
+ from typing import Optional
15
+
16
+ from fastapi import APIRouter, HTTPException, Body, Query, Path
17
+ from loguru import logger
18
+
19
+ from basic_memory.deps import (
20
+ ProjectServiceDep,
21
+ ProjectRepositoryDep,
22
+ )
23
+ from basic_memory.schemas.project_info import (
24
+ ProjectItem,
25
+ ProjectStatusResponse,
26
+ )
27
+ from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
28
+ from basic_memory.utils import normalize_project_path, generate_permalink
29
+
30
+ router = APIRouter(prefix="/projects", tags=["project_management-v2"])
31
+
32
+
33
+ @router.post("/resolve", response_model=ProjectResolveResponse)
34
+ async def resolve_project_identifier(
35
+ data: ProjectResolveRequest,
36
+ project_repository: ProjectRepositoryDep,
37
+ ) -> ProjectResolveResponse:
38
+ """Resolve a project identifier (name, permalink, or external_id) to project info.
39
+
40
+ This endpoint provides efficient lookup of projects by various identifiers
41
+ without needing to fetch the entire project list. Supports:
42
+ - External ID (UUID string) - preferred stable identifier
43
+ - Permalink
44
+ - Case-insensitive name matching
45
+
46
+ Args:
47
+ data: Request containing the identifier to resolve
48
+
49
+ Returns:
50
+ Project information including the external_id (UUID)
51
+
52
+ Raises:
53
+ HTTPException: 404 if project not found
54
+
55
+ Example:
56
+ POST /v2/projects/resolve
57
+ {"identifier": "my-project"}
58
+
59
+ Returns:
60
+ {
61
+ "external_id": "550e8400-e29b-41d4-a716-446655440000",
62
+ "project_id": 1,
63
+ "name": "my-project",
64
+ "permalink": "my-project",
65
+ "path": "/path/to/project",
66
+ "is_active": true,
67
+ "is_default": false,
68
+ "resolution_method": "name"
69
+ }
70
+ """
71
+ logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
72
+
73
+ # Generate permalink for comparison
74
+ identifier_permalink = generate_permalink(data.identifier)
75
+
76
+ resolution_method = "name"
77
+ project = None
78
+
79
+ # Try external_id first (UUID format)
80
+ project = await project_repository.get_by_external_id(data.identifier)
81
+ if project:
82
+ resolution_method = "external_id"
83
+
84
+ # If not found by external_id, try by permalink (exact match)
85
+ if not project:
86
+ project = await project_repository.get_by_permalink(identifier_permalink)
87
+ if project:
88
+ resolution_method = "permalink"
89
+
90
+ # If not found by permalink, try case-insensitive name search
91
+ if not project:
92
+ project = await project_repository.get_by_name_case_insensitive(data.identifier)
93
+ if project:
94
+ resolution_method = "name" # pragma: no cover
95
+
96
+ if not project:
97
+ raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
98
+
99
+ return ProjectResolveResponse(
100
+ external_id=project.external_id,
101
+ project_id=project.id,
102
+ name=project.name,
103
+ permalink=generate_permalink(project.name),
104
+ path=normalize_project_path(project.path),
105
+ is_active=project.is_active if hasattr(project, "is_active") else True,
106
+ is_default=project.is_default or False,
107
+ resolution_method=resolution_method,
108
+ )
109
+
110
+
111
+ @router.get("/{project_id}", response_model=ProjectItem)
112
+ async def get_project_by_id(
113
+ project_repository: ProjectRepositoryDep,
114
+ project_id: str = Path(..., description="Project external ID (UUID)"),
115
+ ) -> ProjectItem:
116
+ """Get project by its external ID (UUID).
117
+
118
+ This is the primary project retrieval method in v2, using stable UUID
119
+ identifiers that won't change with project renames.
120
+
121
+ Args:
122
+ project_id: External ID (UUID string)
123
+
124
+ Returns:
125
+ Project information including external_id
126
+
127
+ Raises:
128
+ HTTPException: 404 if project not found
129
+
130
+ Example:
131
+ GET /v2/projects/550e8400-e29b-41d4-a716-446655440000
132
+ """
133
+ logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
134
+
135
+ project = await project_repository.get_by_external_id(project_id)
136
+ if not project:
137
+ raise HTTPException(
138
+ status_code=404, detail=f"Project with external_id '{project_id}' not found"
139
+ )
140
+
141
+ return ProjectItem(
142
+ id=project.id,
143
+ external_id=project.external_id,
144
+ name=project.name,
145
+ path=normalize_project_path(project.path),
146
+ is_default=project.is_default or False,
147
+ )
148
+
149
+
150
+ @router.patch("/{project_id}", response_model=ProjectStatusResponse)
151
+ async def update_project_by_id(
152
+ project_service: ProjectServiceDep,
153
+ project_repository: ProjectRepositoryDep,
154
+ project_id: str = Path(..., description="Project external ID (UUID)"),
155
+ path: Optional[str] = Body(None, description="New absolute path for the project"),
156
+ is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
157
+ ) -> ProjectStatusResponse:
158
+ """Update a project's information by external ID.
159
+
160
+ Args:
161
+ project_id: External ID (UUID string)
162
+ path: Optional new absolute path for the project
163
+ is_active: Optional status update for the project
164
+
165
+ Returns:
166
+ Response confirming the project was updated
167
+
168
+ Raises:
169
+ HTTPException: 400 if validation fails, 404 if project not found
170
+
171
+ Example:
172
+ PATCH /v2/projects/550e8400-e29b-41d4-a716-446655440000
173
+ {"path": "/new/path"}
174
+ """
175
+ logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
176
+
177
+ try:
178
+ # Validate that path is absolute if provided
179
+ if path and not os.path.isabs(path):
180
+ raise HTTPException(status_code=400, detail="Path must be absolute")
181
+
182
+ # Get original project info for the response
183
+ old_project = await project_repository.get_by_external_id(project_id)
184
+ if not old_project:
185
+ raise HTTPException(
186
+ status_code=404, detail=f"Project with external_id '{project_id}' not found"
187
+ )
188
+
189
+ old_project_info = ProjectItem(
190
+ id=old_project.id,
191
+ external_id=old_project.external_id,
192
+ name=old_project.name,
193
+ path=old_project.path,
194
+ is_default=old_project.is_default or False,
195
+ )
196
+
197
+ # Update using project name (service layer still uses names internally)
198
+ if path:
199
+ await project_service.move_project(old_project.name, path)
200
+ elif is_active is not None:
201
+ await project_service.update_project(old_project.name, is_active=is_active)
202
+
203
+ # Get updated project info (use the same external_id)
204
+ updated_project = await project_repository.get_by_external_id(project_id)
205
+ if not updated_project: # pragma: no cover
206
+ raise HTTPException(
207
+ status_code=404,
208
+ detail=f"Project with external_id '{project_id}' not found after update",
209
+ )
210
+
211
+ return ProjectStatusResponse(
212
+ message=f"Project '{updated_project.name}' updated successfully",
213
+ status="success",
214
+ default=old_project.is_default or False,
215
+ old_project=old_project_info,
216
+ new_project=ProjectItem(
217
+ id=updated_project.id,
218
+ external_id=updated_project.external_id,
219
+ name=updated_project.name,
220
+ path=updated_project.path,
221
+ is_default=updated_project.is_default or False,
222
+ ),
223
+ )
224
+ except ValueError as e: # pragma: no cover
225
+ raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
226
+
227
+
228
+ @router.delete("/{project_id}", response_model=ProjectStatusResponse)
229
+ async def delete_project_by_id(
230
+ project_service: ProjectServiceDep,
231
+ project_repository: ProjectRepositoryDep,
232
+ project_id: str = Path(..., description="Project external ID (UUID)"),
233
+ delete_notes: bool = Query(
234
+ False, description="If True, delete project directory from filesystem"
235
+ ),
236
+ ) -> ProjectStatusResponse:
237
+ """Delete a project by external ID.
238
+
239
+ Args:
240
+ project_id: External ID (UUID string)
241
+ delete_notes: If True, delete the project directory from the filesystem
242
+
243
+ Returns:
244
+ Response confirming the project was deleted
245
+
246
+ Raises:
247
+ HTTPException: 400 if trying to delete default project, 404 if not found
248
+
249
+ Example:
250
+ DELETE /v2/projects/550e8400-e29b-41d4-a716-446655440000?delete_notes=false
251
+ """
252
+ logger.info(
253
+ f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
254
+ )
255
+
256
+ try:
257
+ old_project = await project_repository.get_by_external_id(project_id)
258
+ if not old_project:
259
+ raise HTTPException(
260
+ status_code=404, detail=f"Project with external_id '{project_id}' not found"
261
+ )
262
+
263
+ # Check if trying to delete the default project
264
+ # Use is_default from database, not ConfigManager (which doesn't work in cloud mode)
265
+ if old_project.is_default:
266
+ available_projects = await project_service.list_projects()
267
+ other_projects = [
268
+ p.name for p in available_projects if p.external_id != project_id
269
+ ]
270
+ detail = f"Cannot delete default project '{old_project.name}'. "
271
+ if other_projects:
272
+ detail += ( # pragma: no cover
273
+ f"Set another project as default first. Available: {', '.join(other_projects)}"
274
+ )
275
+ else:
276
+ detail += "This is the only project in your configuration." # pragma: no cover
277
+ raise HTTPException(status_code=400, detail=detail)
278
+
279
+ # Delete using project name (service layer still uses names internally)
280
+ await project_service.remove_project(old_project.name, delete_notes=delete_notes)
281
+
282
+ return ProjectStatusResponse(
283
+ message=f"Project '{old_project.name}' removed successfully",
284
+ status="success",
285
+ default=False,
286
+ old_project=ProjectItem(
287
+ id=old_project.id,
288
+ external_id=old_project.external_id,
289
+ name=old_project.name,
290
+ path=old_project.path,
291
+ is_default=old_project.is_default or False,
292
+ ),
293
+ new_project=None,
294
+ )
295
+ except ValueError as e: # pragma: no cover
296
+ raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
297
+
298
+
299
+ @router.put("/{project_id}/default", response_model=ProjectStatusResponse)
300
+ async def set_default_project_by_id(
301
+ project_service: ProjectServiceDep,
302
+ project_repository: ProjectRepositoryDep,
303
+ project_id: str = Path(..., description="Project external ID (UUID)"),
304
+ ) -> ProjectStatusResponse:
305
+ """Set a project as the default project by external ID.
306
+
307
+ Args:
308
+ project_id: External ID (UUID string) to set as default
309
+
310
+ Returns:
311
+ Response confirming the project was set as default
312
+
313
+ Raises:
314
+ HTTPException: 404 if project not found
315
+
316
+ Example:
317
+ PUT /v2/projects/550e8400-e29b-41d4-a716-446655440000/default
318
+ """
319
+ logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
320
+
321
+ try:
322
+ # Get the old default project from database
323
+ default_project = await project_repository.get_default_project()
324
+ if not default_project:
325
+ raise HTTPException( # pragma: no cover
326
+ status_code=404, detail="No default project is currently set"
327
+ )
328
+
329
+ # Get the new default project by external_id
330
+ new_default_project = await project_repository.get_by_external_id(project_id)
331
+ if not new_default_project:
332
+ raise HTTPException(
333
+ status_code=404, detail=f"Project with external_id '{project_id}' not found"
334
+ )
335
+
336
+ # Set as default using project name (service layer still uses names internally)
337
+ await project_service.set_default_project(new_default_project.name)
338
+
339
+ return ProjectStatusResponse(
340
+ message=f"Project '{new_default_project.name}' set as default successfully",
341
+ status="success",
342
+ default=True,
343
+ old_project=ProjectItem(
344
+ id=default_project.id,
345
+ external_id=default_project.external_id,
346
+ name=default_project.name,
347
+ path=default_project.path,
348
+ is_default=False,
349
+ ),
350
+ new_project=ProjectItem(
351
+ id=new_default_project.id,
352
+ external_id=new_default_project.external_id,
353
+ name=new_default_project.name,
354
+ path=new_default_project.path,
355
+ is_default=True,
356
+ ),
357
+ )
358
+ except ValueError as e: # pragma: no cover
359
+ raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover