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.
- basic_memory/__init__.py +2 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +26 -7
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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/{
|
|
208
|
+
@router.get("/entities/{identifier:path}", response_model=EntityResponse)
|
|
108
209
|
async def get_entity(
|
|
109
210
|
entity_service: EntityServiceDep,
|
|
110
|
-
|
|
211
|
+
link_resolver: LinkResolverDep,
|
|
212
|
+
identifier: str,
|
|
111
213
|
) -> EntityResponse:
|
|
112
|
-
"""Get a specific entity by
|
|
214
|
+
"""Get a specific entity by file path or permalink..
|
|
113
215
|
|
|
114
216
|
Args:
|
|
115
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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))
|