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
@@ -10,6 +10,11 @@ from basic_memory.deps import (
10
10
  get_search_service,
11
11
  SearchServiceDep,
12
12
  LinkResolverDep,
13
+ ProjectPathDep,
14
+ FileServiceDep,
15
+ ProjectConfigDep,
16
+ AppConfigDep,
17
+ SyncServiceDep,
13
18
  )
14
19
  from basic_memory.schemas import (
15
20
  EntityListResponse,
@@ -17,8 +22,8 @@ from basic_memory.schemas import (
17
22
  DeleteEntitiesResponse,
18
23
  DeleteEntitiesRequest,
19
24
  )
25
+ from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
20
26
  from basic_memory.schemas.base import Permalink, Entity
21
- from basic_memory.services.exceptions import EntityNotFoundError
22
27
 
23
28
  router = APIRouter(prefix="/knowledge", tags=["knowledge"])
24
29
 
@@ -44,43 +49,37 @@ async def create_entity(
44
49
  result = EntityResponse.model_validate(entity)
45
50
 
46
51
  logger.info(
47
- "API response",
48
- endpoint="create_entity",
49
- title=result.title,
50
- permalink=result.permalink,
51
- status_code=201,
52
+ f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
52
53
  )
53
54
  return result
54
55
 
55
56
 
56
57
  @router.put("/entities/{permalink:path}", response_model=EntityResponse)
57
58
  async def create_or_update_entity(
59
+ project: ProjectPathDep,
58
60
  permalink: Permalink,
59
61
  data: Entity,
60
62
  response: Response,
61
63
  background_tasks: BackgroundTasks,
62
64
  entity_service: EntityServiceDep,
63
65
  search_service: SearchServiceDep,
66
+ file_service: FileServiceDep,
67
+ sync_service: SyncServiceDep,
64
68
  ) -> EntityResponse:
65
69
  """Create or update an entity. If entity exists, it will be updated, otherwise created."""
66
70
  logger.info(
67
- "API request",
68
- endpoint="create_or_update_entity",
69
- permalink=permalink,
70
- entity_type=data.entity_type,
71
- title=data.title,
71
+ f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
72
72
  )
73
73
 
74
74
  # Validate permalink matches
75
75
  if data.permalink != permalink:
76
76
  logger.warning(
77
- "API validation error",
78
- endpoint="create_or_update_entity",
79
- permalink=permalink,
80
- data_permalink=data.permalink,
81
- error="Permalink mismatch",
77
+ f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
78
+ )
79
+ raise HTTPException(
80
+ status_code=400,
81
+ detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
82
82
  )
83
- raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
84
83
 
85
84
  # Try create_or_update operation
86
85
  entity, created = await entity_service.create_or_update_entity(data)
@@ -88,41 +87,144 @@ async def create_or_update_entity(
88
87
 
89
88
  # reindex
90
89
  await search_service.index_entity(entity, background_tasks=background_tasks)
90
+
91
+ # Attempt immediate relation resolution when creating new entities
92
+ # This helps resolve forward references when related entities are created in the same session
93
+ if created:
94
+ try:
95
+ await sync_service.resolve_relations()
96
+ logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
97
+ except Exception as e: # pragma: no cover
98
+ # Don't fail the entire request if relation resolution fails
99
+ logger.warning(f"Failed to resolve relations after entity creation: {e}")
100
+
91
101
  result = EntityResponse.model_validate(entity)
92
102
 
93
103
  logger.info(
94
- "API response",
95
- endpoint="create_or_update_entity",
96
- title=result.title,
97
- permalink=result.permalink,
98
- created=created,
99
- status_code=response.status_code,
104
+ f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
100
105
  )
101
106
  return result
102
107
 
103
108
 
109
+ @router.patch("/entities/{identifier:path}", response_model=EntityResponse)
110
+ async def edit_entity(
111
+ identifier: str,
112
+ data: EditEntityRequest,
113
+ background_tasks: BackgroundTasks,
114
+ entity_service: EntityServiceDep,
115
+ search_service: SearchServiceDep,
116
+ ) -> EntityResponse:
117
+ """Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
118
+
119
+ This endpoint allows for targeted edits without requiring the full entity content.
120
+ """
121
+ logger.info(
122
+ f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
123
+ )
124
+
125
+ try:
126
+ # Edit the entity using the service
127
+ entity = await entity_service.edit_entity(
128
+ identifier=identifier,
129
+ operation=data.operation,
130
+ content=data.content,
131
+ section=data.section,
132
+ find_text=data.find_text,
133
+ expected_replacements=data.expected_replacements,
134
+ )
135
+
136
+ # Reindex the updated entity
137
+ await search_service.index_entity(entity, background_tasks=background_tasks)
138
+
139
+ # Return the updated entity response
140
+ result = EntityResponse.model_validate(entity)
141
+
142
+ logger.info(
143
+ "API response",
144
+ endpoint="edit_entity",
145
+ identifier=identifier,
146
+ operation=data.operation,
147
+ permalink=result.permalink,
148
+ status_code=200,
149
+ )
150
+
151
+ return result
152
+
153
+ except Exception as e:
154
+ logger.error(f"Error editing entity: {e}")
155
+ raise HTTPException(status_code=400, detail=str(e))
156
+
157
+
158
+ @router.post("/move")
159
+ async def move_entity(
160
+ data: MoveEntityRequest,
161
+ background_tasks: BackgroundTasks,
162
+ entity_service: EntityServiceDep,
163
+ project_config: ProjectConfigDep,
164
+ app_config: AppConfigDep,
165
+ search_service: SearchServiceDep,
166
+ ) -> EntityResponse:
167
+ """Move an entity to a new file location with project consistency.
168
+
169
+ This endpoint moves a note to a different path while maintaining project
170
+ consistency and optionally updating permalinks based on configuration.
171
+ """
172
+ logger.info(
173
+ f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
174
+ )
175
+
176
+ try:
177
+ # Move the entity using the service
178
+ moved_entity = await entity_service.move_entity(
179
+ identifier=data.identifier,
180
+ destination_path=data.destination_path,
181
+ project_config=project_config,
182
+ app_config=app_config,
183
+ )
184
+
185
+ # Get the moved entity to reindex it
186
+ entity = await entity_service.link_resolver.resolve_link(data.destination_path)
187
+ if entity:
188
+ await search_service.index_entity(entity, background_tasks=background_tasks)
189
+
190
+ logger.info(
191
+ "API response",
192
+ endpoint="move_entity",
193
+ identifier=data.identifier,
194
+ destination=data.destination_path,
195
+ status_code=200,
196
+ )
197
+ result = EntityResponse.model_validate(moved_entity)
198
+ return result
199
+
200
+ except Exception as e:
201
+ logger.error(f"Error moving entity: {e}")
202
+ raise HTTPException(status_code=400, detail=str(e))
203
+
204
+
104
205
  ## Read endpoints
105
206
 
106
207
 
107
- @router.get("/entities/{permalink:path}", response_model=EntityResponse)
208
+ @router.get("/entities/{identifier:path}", response_model=EntityResponse)
108
209
  async def get_entity(
109
210
  entity_service: EntityServiceDep,
110
- permalink: str,
211
+ link_resolver: LinkResolverDep,
212
+ identifier: str,
111
213
  ) -> EntityResponse:
112
- """Get a specific entity by ID.
214
+ """Get a specific entity by file path or permalink..
113
215
 
114
216
  Args:
115
- permalink: Entity path ID
116
- content: If True, include full file content
217
+ identifier: Entity file path or permalink
117
218
  :param entity_service: EntityService
219
+ :param link_resolver: LinkResolver
118
220
  """
119
- logger.info(f"request: get_entity with permalink={permalink}")
120
- try:
121
- entity = await entity_service.get_by_permalink(permalink)
122
- result = EntityResponse.model_validate(entity)
123
- return result
124
- except EntityNotFoundError:
125
- raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
221
+ logger.info(f"request: get_entity with identifier={identifier}")
222
+ entity = await link_resolver.resolve_link(identifier)
223
+ if not entity:
224
+ raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
225
+
226
+ result = EntityResponse.model_validate(entity)
227
+ return result
126
228
 
127
229
 
128
230
  @router.get("/entities", response_model=EntityListResponse)
@@ -161,8 +263,8 @@ async def delete_entity(
161
263
  # Delete the entity
162
264
  deleted = await entity_service.delete_entity(entity.permalink or entity.id)
163
265
 
164
- # Remove from search index
165
- background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
266
+ # Remove from search index (entity, observations, and relations)
267
+ background_tasks.add_task(search_service.handle_delete, entity)
166
268
 
167
269
  result = DeleteEntitiesResponse(deleted=deleted)
168
270
  return result
@@ -0,0 +1,78 @@
1
+ """Management router for basic-memory API."""
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, Request
6
+ from loguru import logger
7
+ from pydantic import BaseModel
8
+
9
+ from basic_memory.config import app_config
10
+ from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
11
+
12
+ router = APIRouter(prefix="/management", tags=["management"])
13
+
14
+
15
+ class WatchStatusResponse(BaseModel):
16
+ """Response model for watch status."""
17
+
18
+ running: bool
19
+ """Whether the watch service is currently running."""
20
+
21
+
22
+ @router.get("/watch/status", response_model=WatchStatusResponse)
23
+ async def get_watch_status(request: Request) -> WatchStatusResponse:
24
+ """Get the current status of the watch service."""
25
+ return WatchStatusResponse(
26
+ running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
27
+ )
28
+
29
+
30
+ @router.post("/watch/start", response_model=WatchStatusResponse)
31
+ async def start_watch_service(
32
+ request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
33
+ ) -> WatchStatusResponse:
34
+ """Start the watch service if it's not already running."""
35
+
36
+ # needed because of circular imports from sync -> app
37
+ from basic_memory.sync import WatchService
38
+ from basic_memory.sync.background_sync import create_background_sync_task
39
+
40
+ if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
41
+ # Watch service is already running
42
+ return WatchStatusResponse(running=True)
43
+
44
+ # Create and start a new watch service
45
+ logger.info("Starting watch service via management API")
46
+
47
+ # Get services needed for the watch task
48
+ watch_service = WatchService(
49
+ app_config=app_config,
50
+ project_repository=project_repository,
51
+ )
52
+
53
+ # Create and store the task
54
+ watch_task = create_background_sync_task(sync_service, watch_service)
55
+ request.app.state.watch_task = watch_task
56
+
57
+ return WatchStatusResponse(running=True)
58
+
59
+
60
+ @router.post("/watch/stop", response_model=WatchStatusResponse)
61
+ async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
62
+ """Stop the watch service if it's running."""
63
+ if request.app.state.watch_task is None or request.app.state.watch_task.done():
64
+ # Watch service is not running
65
+ return WatchStatusResponse(running=False)
66
+
67
+ # Cancel the running task
68
+ logger.info("Stopping watch service via management API")
69
+ request.app.state.watch_task.cancel()
70
+
71
+ # Wait for it to be properly cancelled
72
+ try:
73
+ await request.app.state.watch_task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ request.app.state.watch_task = None
78
+ return WatchStatusResponse(running=False)
@@ -1,78 +1,22 @@
1
1
  """Routes for memory:// URI operations."""
2
2
 
3
- from typing import Annotated
3
+ from typing import Annotated, Optional
4
4
 
5
- from dateparser import parse
6
5
  from fastapi import APIRouter, Query
7
6
  from loguru import logger
8
7
 
9
8
  from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
10
- from basic_memory.repository import EntityRepository
11
- from basic_memory.repository.search_repository import SearchIndexRow
12
- from basic_memory.schemas.base import TimeFrame
9
+ from basic_memory.schemas.base import TimeFrame, parse_timeframe
13
10
  from basic_memory.schemas.memory import (
14
11
  GraphContext,
15
- RelationSummary,
16
- EntitySummary,
17
- ObservationSummary,
18
- MemoryMetadata,
19
12
  normalize_memory_url,
20
13
  )
21
14
  from basic_memory.schemas.search import SearchItemType
22
- from basic_memory.services.context_service import ContextResultRow
15
+ from basic_memory.api.routers.utils import to_graph_context
23
16
 
24
17
  router = APIRouter(prefix="/memory", tags=["memory"])
25
18
 
26
19
 
27
- async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
28
- # return results
29
- async def to_summary(item: SearchIndexRow | ContextResultRow):
30
- match item.type:
31
- case SearchItemType.ENTITY:
32
- return EntitySummary(
33
- title=item.title, # pyright: ignore
34
- permalink=item.permalink,
35
- content=item.content,
36
- file_path=item.file_path,
37
- created_at=item.created_at,
38
- )
39
- case SearchItemType.OBSERVATION:
40
- return ObservationSummary(
41
- title=item.title, # pyright: ignore
42
- file_path=item.file_path,
43
- category=item.category, # pyright: ignore
44
- content=item.content, # pyright: ignore
45
- permalink=item.permalink, # pyright: ignore
46
- created_at=item.created_at,
47
- )
48
- case SearchItemType.RELATION:
49
- from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
50
- to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
51
- return RelationSummary(
52
- title=item.title, # pyright: ignore
53
- file_path=item.file_path,
54
- permalink=item.permalink, # pyright: ignore
55
- relation_type=item.type,
56
- from_entity=from_entity.permalink, # pyright: ignore
57
- to_entity=to_entity.permalink if to_entity else None,
58
- created_at=item.created_at,
59
- )
60
- case _: # pragma: no cover
61
- raise ValueError(f"Unexpected type: {item.type}")
62
-
63
- primary_results = [await to_summary(r) for r in context["primary_results"]]
64
- related_results = [await to_summary(r) for r in context["related_results"]]
65
- metadata = MemoryMetadata.model_validate(context["metadata"])
66
- # Transform to GraphContext
67
- return GraphContext(
68
- primary_results=primary_results,
69
- related_results=related_results,
70
- metadata=metadata,
71
- page=page,
72
- page_size=page_size,
73
- )
74
-
75
-
76
20
  @router.get("/recent", response_model=GraphContext)
77
21
  async def recent(
78
22
  context_service: ContextServiceDep,
@@ -95,7 +39,7 @@ async def recent(
95
39
  f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
96
40
  )
97
41
  # Parse timeframe
98
- since = parse(timeframe)
42
+ since = parse_timeframe(timeframe)
99
43
  limit = page_size
100
44
  offset = (page - 1) * page_size
101
45
 
@@ -119,7 +63,7 @@ async def get_memory_context(
119
63
  entity_repository: EntityRepositoryDep,
120
64
  uri: str,
121
65
  depth: int = 1,
122
- timeframe: TimeFrame = "7d",
66
+ timeframe: Optional[TimeFrame] = None,
123
67
  page: int = 1,
124
68
  page_size: int = 10,
125
69
  max_related: int = 10,
@@ -133,7 +77,7 @@ async def get_memory_context(
133
77
  memory_url = normalize_memory_url(uri)
134
78
 
135
79
  # Parse timeframe
136
- since = parse(timeframe)
80
+ since = parse_timeframe(timeframe) if timeframe else None
137
81
  limit = page_size
138
82
  offset = (page - 1) * page_size
139
83
 
@@ -0,0 +1,234 @@
1
+ """Router for project management."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Path, Body
4
+ from typing import Optional
5
+
6
+ from basic_memory.deps import ProjectServiceDep, ProjectPathDep
7
+ from basic_memory.schemas import ProjectInfoResponse
8
+ from basic_memory.schemas.project_info import (
9
+ ProjectList,
10
+ ProjectItem,
11
+ ProjectInfoRequest,
12
+ ProjectStatusResponse,
13
+ )
14
+
15
+ # Router for resources in a specific project
16
+ project_router = APIRouter(prefix="/project", tags=["project"])
17
+
18
+ # Router for managing project resources
19
+ project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
20
+
21
+
22
+ @project_router.get("/info", response_model=ProjectInfoResponse)
23
+ async def get_project_info(
24
+ project_service: ProjectServiceDep,
25
+ project: ProjectPathDep,
26
+ ) -> ProjectInfoResponse:
27
+ """Get comprehensive information about the specified Basic Memory project."""
28
+ return await project_service.get_project_info(project)
29
+
30
+
31
+ # Update a project
32
+ @project_router.patch("/{name}", response_model=ProjectStatusResponse)
33
+ async def update_project(
34
+ project_service: ProjectServiceDep,
35
+ project_name: str = Path(..., description="Name of the project to update"),
36
+ path: Optional[str] = Body(None, description="New path for the project"),
37
+ is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
38
+ ) -> ProjectStatusResponse:
39
+ """Update a project's information in configuration and database.
40
+
41
+ Args:
42
+ project_name: The name of the project to update
43
+ path: Optional new path for the project
44
+ is_active: Optional status update for the project
45
+
46
+ Returns:
47
+ Response confirming the project was updated
48
+ """
49
+ try: # pragma: no cover
50
+ # Get original project info for the response
51
+ old_project_info = ProjectItem(
52
+ name=project_name,
53
+ path=project_service.projects.get(project_name, ""),
54
+ )
55
+
56
+ await project_service.update_project(project_name, updated_path=path, is_active=is_active)
57
+
58
+ # Get updated project info
59
+ updated_path = path if path else project_service.projects.get(project_name, "")
60
+
61
+ return ProjectStatusResponse(
62
+ message=f"Project '{project_name}' updated successfully",
63
+ status="success",
64
+ default=(project_name == project_service.default_project),
65
+ old_project=old_project_info,
66
+ new_project=ProjectItem(name=project_name, path=updated_path),
67
+ )
68
+ except ValueError as e: # pragma: no cover
69
+ raise HTTPException(status_code=400, detail=str(e))
70
+
71
+
72
+ # List all available projects
73
+ @project_resource_router.get("/projects", response_model=ProjectList)
74
+ async def list_projects(
75
+ project_service: ProjectServiceDep,
76
+ ) -> ProjectList:
77
+ """List all configured projects.
78
+
79
+ Returns:
80
+ A list of all projects with metadata
81
+ """
82
+ projects = await project_service.list_projects()
83
+ default_project = project_service.default_project
84
+
85
+ project_items = [
86
+ ProjectItem(
87
+ name=project.name,
88
+ path=project.path,
89
+ is_default=project.is_default or False,
90
+ )
91
+ for project in projects
92
+ ]
93
+
94
+ return ProjectList(
95
+ projects=project_items,
96
+ default_project=default_project,
97
+ )
98
+
99
+
100
+ # Add a new project
101
+ @project_resource_router.post("/projects", response_model=ProjectStatusResponse)
102
+ async def add_project(
103
+ project_data: ProjectInfoRequest,
104
+ project_service: ProjectServiceDep,
105
+ ) -> ProjectStatusResponse:
106
+ """Add a new project to configuration and database.
107
+
108
+ Args:
109
+ project_data: The project name and path, with option to set as default
110
+
111
+ Returns:
112
+ Response confirming the project was added
113
+ """
114
+ try: # pragma: no cover
115
+ await project_service.add_project(
116
+ project_data.name, project_data.path, set_default=project_data.set_default
117
+ )
118
+
119
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
120
+ message=f"Project '{project_data.name}' added successfully",
121
+ status="success",
122
+ default=project_data.set_default,
123
+ new_project=ProjectItem(
124
+ name=project_data.name, path=project_data.path, is_default=project_data.set_default
125
+ ),
126
+ )
127
+ except ValueError as e: # pragma: no cover
128
+ raise HTTPException(status_code=400, detail=str(e))
129
+
130
+
131
+ # Remove a project
132
+ @project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
133
+ async def remove_project(
134
+ project_service: ProjectServiceDep,
135
+ name: str = Path(..., description="Name of the project to remove"),
136
+ ) -> ProjectStatusResponse:
137
+ """Remove a project from configuration and database.
138
+
139
+ Args:
140
+ name: The name of the project to remove
141
+
142
+ Returns:
143
+ Response confirming the project was removed
144
+ """
145
+ try:
146
+ old_project = await project_service.get_project(name)
147
+ if not old_project: # pragma: no cover
148
+ raise HTTPException(
149
+ status_code=404, detail=f"Project: '{name}' does not exist"
150
+ ) # pragma: no cover
151
+
152
+ await project_service.remove_project(name)
153
+
154
+ return ProjectStatusResponse(
155
+ message=f"Project '{name}' removed successfully",
156
+ status="success",
157
+ default=False,
158
+ old_project=ProjectItem(name=old_project.name, path=old_project.path),
159
+ new_project=None,
160
+ )
161
+ except ValueError as e: # pragma: no cover
162
+ raise HTTPException(status_code=400, detail=str(e))
163
+
164
+
165
+ # Set a project as default
166
+ @project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
167
+ async def set_default_project(
168
+ project_service: ProjectServiceDep,
169
+ name: str = Path(..., description="Name of the project to set as default"),
170
+ ) -> ProjectStatusResponse:
171
+ """Set a project as the default project.
172
+
173
+ Args:
174
+ name: The name of the project to set as default
175
+
176
+ Returns:
177
+ Response confirming the project was set as default
178
+ """
179
+ try:
180
+ # Get the old default project
181
+ default_name = project_service.default_project
182
+ default_project = await project_service.get_project(default_name)
183
+ if not default_project: # pragma: no cover
184
+ raise HTTPException( # pragma: no cover
185
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
186
+ )
187
+
188
+ # get the new project
189
+ new_default_project = await project_service.get_project(name)
190
+ if not new_default_project: # pragma: no cover
191
+ raise HTTPException(
192
+ status_code=404, detail=f"Project: '{name}' does not exist"
193
+ ) # pragma: no cover
194
+
195
+ await project_service.set_default_project(name)
196
+
197
+ return ProjectStatusResponse(
198
+ message=f"Project '{name}' set as default successfully",
199
+ status="success",
200
+ default=True,
201
+ old_project=ProjectItem(name=default_name, path=default_project.path),
202
+ new_project=ProjectItem(
203
+ name=name,
204
+ path=new_default_project.path,
205
+ is_default=True,
206
+ ),
207
+ )
208
+ except ValueError as e: # pragma: no cover
209
+ raise HTTPException(status_code=400, detail=str(e))
210
+
211
+
212
+ # Synchronize projects between config and database
213
+ @project_resource_router.post("/sync", response_model=ProjectStatusResponse)
214
+ async def synchronize_projects(
215
+ project_service: ProjectServiceDep,
216
+ ) -> ProjectStatusResponse:
217
+ """Synchronize projects between configuration file and database.
218
+
219
+ Ensures that all projects in the configuration file exist in the database
220
+ and vice versa.
221
+
222
+ Returns:
223
+ Response confirming synchronization was completed
224
+ """
225
+ try: # pragma: no cover
226
+ await project_service.synchronize_projects()
227
+
228
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
229
+ message="Projects synchronized successfully between configuration and database",
230
+ status="success",
231
+ default=False,
232
+ )
233
+ except ValueError as e: # pragma: no cover
234
+ raise HTTPException(status_code=400, detail=str(e))