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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- 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/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +64 -18
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +166 -21
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +9 -64
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +119 -4
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +43 -9
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +28 -12
- basic_memory/cli/commands/import_chatgpt.py +40 -220
- basic_memory/cli/commands/import_claude_conversations.py +41 -168
- basic_memory/cli/commands/import_claude_projects.py +46 -157
- basic_memory/cli/commands/import_memory_json.py +48 -108
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +50 -33
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +8 -7
- basic_memory/config.py +477 -23
- basic_memory/db.py +168 -17
- basic_memory/deps.py +251 -25
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -23
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +411 -62
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +187 -25
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +1 -1
- basic_memory/schemas/search.py +31 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +241 -104
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +590 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +49 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +168 -32
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1180 -109
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +383 -51
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -206
- basic_memory/cli/commands/tools.py +0 -157
- basic_memory/mcp/tools/knowledge.py +0 -68
- basic_memory/mcp/tools/memory.py +0 -170
- basic_memory/mcp/tools/notes.py +0 -202
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -31
- basic_memory-0.7.0.dist-info/METADATA +0 -378
- basic_memory-0.7.0.dist-info/RECORD +0 -82
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Router for project management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from basic_memory.deps import (
|
|
9
|
+
ProjectConfigDep,
|
|
10
|
+
ProjectServiceDep,
|
|
11
|
+
ProjectPathDep,
|
|
12
|
+
SyncServiceDep,
|
|
13
|
+
)
|
|
14
|
+
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
|
|
15
|
+
from basic_memory.schemas.project_info import (
|
|
16
|
+
ProjectList,
|
|
17
|
+
ProjectItem,
|
|
18
|
+
ProjectInfoRequest,
|
|
19
|
+
ProjectStatusResponse,
|
|
20
|
+
)
|
|
21
|
+
from basic_memory.utils import normalize_project_path
|
|
22
|
+
|
|
23
|
+
# Router for resources in a specific project
|
|
24
|
+
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
|
|
25
|
+
project_router = APIRouter(prefix="/project", tags=["project"])
|
|
26
|
+
|
|
27
|
+
# Router for managing project resources
|
|
28
|
+
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@project_router.get("/info", response_model=ProjectInfoResponse)
|
|
32
|
+
async def get_project_info(
|
|
33
|
+
project_service: ProjectServiceDep,
|
|
34
|
+
project: ProjectPathDep,
|
|
35
|
+
) -> ProjectInfoResponse:
|
|
36
|
+
"""Get comprehensive information about the specified Basic Memory project."""
|
|
37
|
+
return await project_service.get_project_info(project)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@project_router.get("/item", response_model=ProjectItem)
|
|
41
|
+
async def get_project(
|
|
42
|
+
project_service: ProjectServiceDep,
|
|
43
|
+
project: ProjectPathDep,
|
|
44
|
+
) -> ProjectItem:
|
|
45
|
+
"""Get bassic info about the specified Basic Memory project."""
|
|
46
|
+
found_project = await project_service.get_project(project)
|
|
47
|
+
if not found_project:
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=404, detail=f"Project: '{project}' does not exist"
|
|
50
|
+
) # pragma: no cover
|
|
51
|
+
|
|
52
|
+
return ProjectItem(
|
|
53
|
+
name=found_project.name,
|
|
54
|
+
path=normalize_project_path(found_project.path),
|
|
55
|
+
is_default=found_project.is_default or False,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Update a project
|
|
60
|
+
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
|
|
61
|
+
async def update_project(
|
|
62
|
+
project_service: ProjectServiceDep,
|
|
63
|
+
name: str = Path(..., description="Name of the project to update"),
|
|
64
|
+
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
|
65
|
+
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
|
66
|
+
) -> ProjectStatusResponse:
|
|
67
|
+
"""Update a project's information in configuration and database.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: The name of the project to update
|
|
71
|
+
path: Optional new absolute path for the project
|
|
72
|
+
is_active: Optional status update for the project
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Response confirming the project was updated
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# Validate that path is absolute if provided
|
|
79
|
+
if path and not os.path.isabs(path):
|
|
80
|
+
raise HTTPException(status_code=400, detail="Path must be absolute")
|
|
81
|
+
|
|
82
|
+
# Get original project info for the response
|
|
83
|
+
old_project_info = ProjectItem(
|
|
84
|
+
name=name,
|
|
85
|
+
path=project_service.projects.get(name, ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if path:
|
|
89
|
+
await project_service.move_project(name, path)
|
|
90
|
+
elif is_active is not None:
|
|
91
|
+
await project_service.update_project(name, is_active=is_active)
|
|
92
|
+
|
|
93
|
+
# Get updated project info
|
|
94
|
+
updated_path = path if path else project_service.projects.get(name, "")
|
|
95
|
+
|
|
96
|
+
return ProjectStatusResponse(
|
|
97
|
+
message=f"Project '{name}' updated successfully",
|
|
98
|
+
status="success",
|
|
99
|
+
default=(name == project_service.default_project),
|
|
100
|
+
old_project=old_project_info,
|
|
101
|
+
new_project=ProjectItem(name=name, path=updated_path),
|
|
102
|
+
)
|
|
103
|
+
except ValueError as e:
|
|
104
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Sync project filesystem
|
|
108
|
+
@project_router.post("/sync")
|
|
109
|
+
async def sync_project(
|
|
110
|
+
background_tasks: BackgroundTasks,
|
|
111
|
+
sync_service: SyncServiceDep,
|
|
112
|
+
project_config: ProjectConfigDep,
|
|
113
|
+
force_full: bool = Query(
|
|
114
|
+
False, description="Force full scan, bypassing watermark optimization"
|
|
115
|
+
),
|
|
116
|
+
run_in_background: bool = Query(True, description="Run in background"),
|
|
117
|
+
):
|
|
118
|
+
"""Force project filesystem sync to database.
|
|
119
|
+
|
|
120
|
+
Scans the project directory and updates the database with any new or modified files.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
background_tasks: FastAPI background tasks
|
|
124
|
+
sync_service: Sync service for this project
|
|
125
|
+
project_config: Project configuration
|
|
126
|
+
force_full: If True, force a full scan even if watermark exists
|
|
127
|
+
run_in_background: If True, run sync in background and return immediately
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Response confirming sync was initiated (background) or SyncReportResponse (foreground)
|
|
131
|
+
"""
|
|
132
|
+
if run_in_background:
|
|
133
|
+
background_tasks.add_task(
|
|
134
|
+
sync_service.sync, project_config.home, project_config.name, force_full=force_full
|
|
135
|
+
)
|
|
136
|
+
logger.info(
|
|
137
|
+
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"status": "sync_started",
|
|
142
|
+
"message": f"Filesystem sync initiated for project '{project_config.name}'",
|
|
143
|
+
}
|
|
144
|
+
else:
|
|
145
|
+
report = await sync_service.sync(
|
|
146
|
+
project_config.home, project_config.name, force_full=force_full
|
|
147
|
+
)
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
|
|
150
|
+
)
|
|
151
|
+
return SyncReportResponse.from_sync_report(report)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@project_router.post("/status", response_model=SyncReportResponse)
|
|
155
|
+
async def project_sync_status(
|
|
156
|
+
sync_service: SyncServiceDep,
|
|
157
|
+
project_config: ProjectConfigDep,
|
|
158
|
+
) -> SyncReportResponse:
|
|
159
|
+
"""Scan directory for changes compared to database state.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
sync_service: Sync service for this project
|
|
163
|
+
project_config: Project configuration
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Scan report with details on files that need syncing
|
|
167
|
+
"""
|
|
168
|
+
logger.info(f"Scanning filesystem for project: {project_config.name}")
|
|
169
|
+
sync_report = await sync_service.scan(project_config.home)
|
|
170
|
+
|
|
171
|
+
return SyncReportResponse.from_sync_report(sync_report)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# List all available projects
|
|
175
|
+
@project_resource_router.get("/projects", response_model=ProjectList)
|
|
176
|
+
async def list_projects(
|
|
177
|
+
project_service: ProjectServiceDep,
|
|
178
|
+
) -> ProjectList:
|
|
179
|
+
"""List all configured projects.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
A list of all projects with metadata
|
|
183
|
+
"""
|
|
184
|
+
projects = await project_service.list_projects()
|
|
185
|
+
default_project = project_service.default_project
|
|
186
|
+
|
|
187
|
+
project_items = [
|
|
188
|
+
ProjectItem(
|
|
189
|
+
name=project.name,
|
|
190
|
+
path=normalize_project_path(project.path),
|
|
191
|
+
is_default=project.is_default or False,
|
|
192
|
+
)
|
|
193
|
+
for project in projects
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
return ProjectList(
|
|
197
|
+
projects=project_items,
|
|
198
|
+
default_project=default_project,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Add a new project
|
|
203
|
+
@project_resource_router.post("/projects", response_model=ProjectStatusResponse, status_code=201)
|
|
204
|
+
async def add_project(
|
|
205
|
+
response: Response,
|
|
206
|
+
project_data: ProjectInfoRequest,
|
|
207
|
+
project_service: ProjectServiceDep,
|
|
208
|
+
) -> ProjectStatusResponse:
|
|
209
|
+
"""Add a new project to configuration and database.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
project_data: The project name and path, with option to set as default
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Response confirming the project was added
|
|
216
|
+
"""
|
|
217
|
+
# Check if project already exists before attempting to add
|
|
218
|
+
existing_project = await project_service.get_project(project_data.name)
|
|
219
|
+
if existing_project:
|
|
220
|
+
# Project exists - check if paths match for true idempotency
|
|
221
|
+
# Normalize paths for comparison (resolve symlinks, etc.)
|
|
222
|
+
from pathlib import Path
|
|
223
|
+
|
|
224
|
+
requested_path = Path(project_data.path).resolve()
|
|
225
|
+
existing_path = Path(existing_project.path).resolve()
|
|
226
|
+
|
|
227
|
+
if requested_path == existing_path:
|
|
228
|
+
# Same name, same path - return 200 OK (idempotent)
|
|
229
|
+
response.status_code = 200
|
|
230
|
+
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
|
231
|
+
message=f"Project '{project_data.name}' already exists",
|
|
232
|
+
status="success",
|
|
233
|
+
default=existing_project.is_default or False,
|
|
234
|
+
new_project=ProjectItem(
|
|
235
|
+
name=existing_project.name,
|
|
236
|
+
path=existing_project.path,
|
|
237
|
+
is_default=existing_project.is_default or False,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
# Same name, different path - this is an error
|
|
242
|
+
raise HTTPException(
|
|
243
|
+
status_code=400,
|
|
244
|
+
detail=f"Project '{project_data.name}' already exists with different path. Existing: {existing_project.path}, Requested: {project_data.path}",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
try: # pragma: no cover
|
|
248
|
+
# The service layer now handles cloud mode validation and path sanitization
|
|
249
|
+
await project_service.add_project(
|
|
250
|
+
project_data.name, project_data.path, set_default=project_data.set_default
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
|
254
|
+
message=f"Project '{project_data.name}' added successfully",
|
|
255
|
+
status="success",
|
|
256
|
+
default=project_data.set_default,
|
|
257
|
+
new_project=ProjectItem(
|
|
258
|
+
name=project_data.name, path=project_data.path, is_default=project_data.set_default
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
except ValueError as e: # pragma: no cover
|
|
262
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Remove a project
|
|
266
|
+
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
|
|
267
|
+
async def remove_project(
|
|
268
|
+
project_service: ProjectServiceDep,
|
|
269
|
+
name: str = Path(..., description="Name of the project to remove"),
|
|
270
|
+
delete_notes: bool = Query(
|
|
271
|
+
False, description="If True, delete project directory from filesystem"
|
|
272
|
+
),
|
|
273
|
+
) -> ProjectStatusResponse:
|
|
274
|
+
"""Remove a project from configuration and database.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
name: The name of the project to remove
|
|
278
|
+
delete_notes: If True, delete the project directory from the filesystem
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Response confirming the project was removed
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
old_project = await project_service.get_project(name)
|
|
285
|
+
if not old_project: # pragma: no cover
|
|
286
|
+
raise HTTPException(
|
|
287
|
+
status_code=404, detail=f"Project: '{name}' does not exist"
|
|
288
|
+
) # pragma: no cover
|
|
289
|
+
|
|
290
|
+
# Check if trying to delete the default project
|
|
291
|
+
if name == project_service.default_project:
|
|
292
|
+
available_projects = await project_service.list_projects()
|
|
293
|
+
other_projects = [p.name for p in available_projects if p.name != name]
|
|
294
|
+
detail = f"Cannot delete default project '{name}'. "
|
|
295
|
+
if other_projects:
|
|
296
|
+
detail += (
|
|
297
|
+
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
detail += "This is the only project in your configuration."
|
|
301
|
+
raise HTTPException(status_code=400, detail=detail)
|
|
302
|
+
|
|
303
|
+
await project_service.remove_project(name, delete_notes=delete_notes)
|
|
304
|
+
|
|
305
|
+
return ProjectStatusResponse(
|
|
306
|
+
message=f"Project '{name}' removed successfully",
|
|
307
|
+
status="success",
|
|
308
|
+
default=False,
|
|
309
|
+
old_project=ProjectItem(name=old_project.name, path=old_project.path),
|
|
310
|
+
new_project=None,
|
|
311
|
+
)
|
|
312
|
+
except ValueError as e: # pragma: no cover
|
|
313
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Set a project as default
|
|
317
|
+
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
|
|
318
|
+
async def set_default_project(
|
|
319
|
+
project_service: ProjectServiceDep,
|
|
320
|
+
name: str = Path(..., description="Name of the project to set as default"),
|
|
321
|
+
) -> ProjectStatusResponse:
|
|
322
|
+
"""Set a project as the default project.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
name: The name of the project to set as default
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Response confirming the project was set as default
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
# Get the old default project
|
|
332
|
+
default_name = project_service.default_project
|
|
333
|
+
default_project = await project_service.get_project(default_name)
|
|
334
|
+
if not default_project: # pragma: no cover
|
|
335
|
+
raise HTTPException( # pragma: no cover
|
|
336
|
+
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# get the new project
|
|
340
|
+
new_default_project = await project_service.get_project(name)
|
|
341
|
+
if not new_default_project: # pragma: no cover
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=404, detail=f"Project: '{name}' does not exist"
|
|
344
|
+
) # pragma: no cover
|
|
345
|
+
|
|
346
|
+
await project_service.set_default_project(name)
|
|
347
|
+
|
|
348
|
+
return ProjectStatusResponse(
|
|
349
|
+
message=f"Project '{name}' set as default successfully",
|
|
350
|
+
status="success",
|
|
351
|
+
default=True,
|
|
352
|
+
old_project=ProjectItem(name=default_name, path=default_project.path),
|
|
353
|
+
new_project=ProjectItem(
|
|
354
|
+
name=name,
|
|
355
|
+
path=new_default_project.path,
|
|
356
|
+
is_default=True,
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
except ValueError as e: # pragma: no cover
|
|
360
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# Get the default project
|
|
364
|
+
@project_resource_router.get("/default", response_model=ProjectItem)
|
|
365
|
+
async def get_default_project(
|
|
366
|
+
project_service: ProjectServiceDep,
|
|
367
|
+
) -> ProjectItem:
|
|
368
|
+
"""Get the default project.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Response with project default information
|
|
372
|
+
"""
|
|
373
|
+
# Get the old default project
|
|
374
|
+
default_name = project_service.default_project
|
|
375
|
+
default_project = await project_service.get_project(default_name)
|
|
376
|
+
if not default_project: # pragma: no cover
|
|
377
|
+
raise HTTPException( # pragma: no cover
|
|
378
|
+
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Synchronize projects between config and database
|
|
385
|
+
@project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
|
|
386
|
+
async def synchronize_projects(
|
|
387
|
+
project_service: ProjectServiceDep,
|
|
388
|
+
) -> ProjectStatusResponse:
|
|
389
|
+
"""Synchronize projects between configuration file and database.
|
|
390
|
+
|
|
391
|
+
Ensures that all projects in the configuration file exist in the database
|
|
392
|
+
and vice versa.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Response confirming synchronization was completed
|
|
396
|
+
"""
|
|
397
|
+
try: # pragma: no cover
|
|
398
|
+
await project_service.synchronize_projects()
|
|
399
|
+
|
|
400
|
+
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
|
401
|
+
message="Projects synchronized successfully between configuration and database",
|
|
402
|
+
status="success",
|
|
403
|
+
default=False,
|
|
404
|
+
)
|
|
405
|
+
except ValueError as e: # pragma: no cover
|
|
406
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Router for prompt-related operations.
|
|
2
|
+
|
|
3
|
+
This router is responsible for rendering various prompts using Handlebars templates.
|
|
4
|
+
It centralizes all prompt formatting logic that was previously in the MCP prompts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from fastapi import APIRouter, HTTPException, status
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
|
12
|
+
from basic_memory.api.template_loader import template_loader
|
|
13
|
+
from basic_memory.schemas.base import parse_timeframe
|
|
14
|
+
from basic_memory.deps import (
|
|
15
|
+
ContextServiceDep,
|
|
16
|
+
EntityRepositoryDep,
|
|
17
|
+
SearchServiceDep,
|
|
18
|
+
EntityServiceDep,
|
|
19
|
+
)
|
|
20
|
+
from basic_memory.schemas.prompt import (
|
|
21
|
+
ContinueConversationRequest,
|
|
22
|
+
SearchPromptRequest,
|
|
23
|
+
PromptResponse,
|
|
24
|
+
PromptMetadata,
|
|
25
|
+
)
|
|
26
|
+
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
|
27
|
+
|
|
28
|
+
router = APIRouter(prefix="/prompt", tags=["prompt"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/continue-conversation", response_model=PromptResponse)
|
|
32
|
+
async def continue_conversation(
|
|
33
|
+
search_service: SearchServiceDep,
|
|
34
|
+
entity_service: EntityServiceDep,
|
|
35
|
+
context_service: ContextServiceDep,
|
|
36
|
+
entity_repository: EntityRepositoryDep,
|
|
37
|
+
request: ContinueConversationRequest,
|
|
38
|
+
) -> PromptResponse:
|
|
39
|
+
"""Generate a prompt for continuing a conversation.
|
|
40
|
+
|
|
41
|
+
This endpoint takes a topic and/or timeframe and generates a prompt with
|
|
42
|
+
relevant context from the knowledge base.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
request: The request parameters
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Formatted continuation prompt with context
|
|
49
|
+
"""
|
|
50
|
+
logger.info(
|
|
51
|
+
f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
|
55
|
+
|
|
56
|
+
# Initialize search results
|
|
57
|
+
search_results = []
|
|
58
|
+
|
|
59
|
+
# Get data needed for template
|
|
60
|
+
if request.topic:
|
|
61
|
+
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
|
62
|
+
results = await search_service.search(query, limit=request.search_items_limit)
|
|
63
|
+
search_results = await to_search_results(entity_service, results)
|
|
64
|
+
|
|
65
|
+
# Build context from results
|
|
66
|
+
all_hierarchical_results = []
|
|
67
|
+
for result in search_results:
|
|
68
|
+
if hasattr(result, "permalink") and result.permalink:
|
|
69
|
+
# Get hierarchical context using the new dataclass-based approach
|
|
70
|
+
context_result = await context_service.build_context(
|
|
71
|
+
result.permalink,
|
|
72
|
+
depth=request.depth,
|
|
73
|
+
since=since,
|
|
74
|
+
max_related=request.related_items_limit,
|
|
75
|
+
include_observations=True, # Include observations for entities
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Process results into the schema format
|
|
79
|
+
graph_context = await to_graph_context(
|
|
80
|
+
context_result, entity_repository=entity_repository
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Add results to our collection (limit to top results for each permalink)
|
|
84
|
+
if graph_context.results:
|
|
85
|
+
all_hierarchical_results.extend(graph_context.results[:3])
|
|
86
|
+
|
|
87
|
+
# Limit to a reasonable number of total results
|
|
88
|
+
all_hierarchical_results = all_hierarchical_results[:10]
|
|
89
|
+
|
|
90
|
+
template_context = {
|
|
91
|
+
"topic": request.topic,
|
|
92
|
+
"timeframe": request.timeframe,
|
|
93
|
+
"hierarchical_results": all_hierarchical_results,
|
|
94
|
+
"has_results": len(all_hierarchical_results) > 0,
|
|
95
|
+
}
|
|
96
|
+
else:
|
|
97
|
+
# If no topic, get recent activity
|
|
98
|
+
context_result = await context_service.build_context(
|
|
99
|
+
types=[SearchItemType.ENTITY],
|
|
100
|
+
depth=request.depth,
|
|
101
|
+
since=since,
|
|
102
|
+
max_related=request.related_items_limit,
|
|
103
|
+
include_observations=True,
|
|
104
|
+
)
|
|
105
|
+
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
|
106
|
+
|
|
107
|
+
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
|
108
|
+
|
|
109
|
+
template_context = {
|
|
110
|
+
"topic": f"Recent Activity from ({request.timeframe})",
|
|
111
|
+
"timeframe": request.timeframe,
|
|
112
|
+
"hierarchical_results": hierarchical_results,
|
|
113
|
+
"has_results": len(hierarchical_results) > 0,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Render template
|
|
118
|
+
rendered_prompt = await template_loader.render(
|
|
119
|
+
"prompts/continue_conversation.hbs", template_context
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Calculate metadata
|
|
123
|
+
# Count items of different types
|
|
124
|
+
observation_count = 0
|
|
125
|
+
relation_count = 0
|
|
126
|
+
entity_count = 0
|
|
127
|
+
|
|
128
|
+
# Get the hierarchical results from the template context
|
|
129
|
+
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
|
130
|
+
|
|
131
|
+
# For topic-based search
|
|
132
|
+
if request.topic:
|
|
133
|
+
for item in hierarchical_results_for_count:
|
|
134
|
+
if hasattr(item, "observations"):
|
|
135
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
136
|
+
|
|
137
|
+
if hasattr(item, "related_results"):
|
|
138
|
+
for related in item.related_results or []:
|
|
139
|
+
if hasattr(related, "type"):
|
|
140
|
+
if related.type == "relation":
|
|
141
|
+
relation_count += 1
|
|
142
|
+
elif related.type == "entity": # pragma: no cover
|
|
143
|
+
entity_count += 1 # pragma: no cover
|
|
144
|
+
# For recent activity
|
|
145
|
+
else:
|
|
146
|
+
for item in hierarchical_results_for_count:
|
|
147
|
+
if hasattr(item, "observations"):
|
|
148
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
149
|
+
|
|
150
|
+
if hasattr(item, "related_results"):
|
|
151
|
+
for related in item.related_results or []:
|
|
152
|
+
if hasattr(related, "type"):
|
|
153
|
+
if related.type == "relation":
|
|
154
|
+
relation_count += 1
|
|
155
|
+
elif related.type == "entity": # pragma: no cover
|
|
156
|
+
entity_count += 1 # pragma: no cover
|
|
157
|
+
|
|
158
|
+
# Build metadata
|
|
159
|
+
metadata = {
|
|
160
|
+
"query": request.topic,
|
|
161
|
+
"timeframe": request.timeframe,
|
|
162
|
+
"search_count": len(search_results)
|
|
163
|
+
if request.topic
|
|
164
|
+
else 0, # Original search results count
|
|
165
|
+
"context_count": len(hierarchical_results_for_count),
|
|
166
|
+
"observation_count": observation_count,
|
|
167
|
+
"relation_count": relation_count,
|
|
168
|
+
"total_items": (
|
|
169
|
+
len(hierarchical_results_for_count)
|
|
170
|
+
+ observation_count
|
|
171
|
+
+ relation_count
|
|
172
|
+
+ entity_count
|
|
173
|
+
),
|
|
174
|
+
"search_limit": request.search_items_limit,
|
|
175
|
+
"context_depth": request.depth,
|
|
176
|
+
"related_limit": request.related_items_limit,
|
|
177
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
181
|
+
|
|
182
|
+
return PromptResponse(
|
|
183
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error(f"Error rendering continue conversation template: {e}")
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
189
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.post("/search", response_model=PromptResponse)
|
|
194
|
+
async def search_prompt(
|
|
195
|
+
search_service: SearchServiceDep,
|
|
196
|
+
entity_service: EntityServiceDep,
|
|
197
|
+
request: SearchPromptRequest,
|
|
198
|
+
page: int = 1,
|
|
199
|
+
page_size: int = 10,
|
|
200
|
+
) -> PromptResponse:
|
|
201
|
+
"""Generate a prompt for search results.
|
|
202
|
+
|
|
203
|
+
This endpoint takes a search query and formats the results into a helpful
|
|
204
|
+
prompt with context and suggestions.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
request: The search parameters
|
|
208
|
+
page: The page number for pagination
|
|
209
|
+
page_size: The number of results per page, defaults to 10
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Formatted search results prompt with context
|
|
213
|
+
"""
|
|
214
|
+
logger.info(f"Generating search prompt, query: {request.query}, timeframe: {request.timeframe}")
|
|
215
|
+
|
|
216
|
+
limit = page_size
|
|
217
|
+
offset = (page - 1) * page_size
|
|
218
|
+
|
|
219
|
+
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
|
220
|
+
results = await search_service.search(query, limit=limit, offset=offset)
|
|
221
|
+
search_results = await to_search_results(entity_service, results)
|
|
222
|
+
|
|
223
|
+
template_context = {
|
|
224
|
+
"query": request.query,
|
|
225
|
+
"timeframe": request.timeframe,
|
|
226
|
+
"results": search_results,
|
|
227
|
+
"has_results": len(search_results) > 0,
|
|
228
|
+
"result_count": len(search_results),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Render template
|
|
233
|
+
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
|
234
|
+
|
|
235
|
+
# Build metadata
|
|
236
|
+
metadata = {
|
|
237
|
+
"query": request.query,
|
|
238
|
+
"timeframe": request.timeframe,
|
|
239
|
+
"search_count": len(search_results),
|
|
240
|
+
"context_count": len(search_results),
|
|
241
|
+
"observation_count": 0, # Search results don't include observations
|
|
242
|
+
"relation_count": 0, # Search results don't include relations
|
|
243
|
+
"total_items": len(search_results),
|
|
244
|
+
"search_limit": limit,
|
|
245
|
+
"context_depth": 0, # No context depth for basic search
|
|
246
|
+
"related_limit": 0, # No related items for basic search
|
|
247
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
251
|
+
|
|
252
|
+
return PromptResponse(
|
|
253
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f"Error rendering search template: {e}")
|
|
257
|
+
raise HTTPException(
|
|
258
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
259
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
260
|
+
)
|