basic-memory 0.7.0__py3-none-any.whl → 0.17.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +130 -20
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -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/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
- basic_memory/api/app.py +87 -20
- basic_memory/api/container.py +133 -0
- 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 +180 -23
- 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 +460 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +136 -11
- basic_memory/api/routers/search_router.py +5 -5
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +181 -0
- basic_memory/api/v2/routers/knowledge_router.py +427 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +359 -0
- basic_memory/api/v2/routers/prompt_router.py +269 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/app.py +80 -10
- basic_memory/cli/auth.py +300 -0
- basic_memory/cli/commands/__init__.py +15 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +127 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +240 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +99 -0
- basic_memory/cli/commands/db.py +87 -12
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +47 -223
- basic_memory/cli/commands/import_claude_conversations.py +48 -171
- basic_memory/cli/commands/import_claude_projects.py +53 -160
- basic_memory/cli/commands/import_memory_json.py +55 -111
- basic_memory/cli/commands/mcp.py +67 -11
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +52 -34
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +14 -6
- basic_memory/config.py +580 -26
- basic_memory/db.py +285 -28
- basic_memory/deps/__init__.py +293 -0
- basic_memory/deps/config.py +26 -0
- basic_memory/deps/db.py +56 -0
- basic_memory/deps/importers.py +200 -0
- basic_memory/deps/projects.py +238 -0
- basic_memory/deps/repositories.py +179 -0
- basic_memory/deps/services.py +480 -0
- basic_memory/deps.py +16 -185
- basic_memory/file_utils.py +318 -54
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +100 -0
- basic_memory/importers/chatgpt_importer.py +245 -0
- basic_memory/importers/claude_conversations_importer.py +192 -0
- basic_memory/importers/claude_projects_importer.py +184 -0
- basic_memory/importers/memory_json_importer.py +128 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/entity_parser.py +182 -23
- basic_memory/markdown/markdown_processor.py +70 -7
- basic_memory/markdown/plugins.py +43 -23
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +38 -14
- basic_memory/mcp/async_client.py +135 -4
- basic_memory/mcp/clients/__init__.py +28 -0
- basic_memory/mcp/clients/directory.py +70 -0
- basic_memory/mcp/clients/knowledge.py +176 -0
- basic_memory/mcp/clients/memory.py +120 -0
- basic_memory/mcp/clients/project.py +89 -0
- basic_memory/mcp/clients/resource.py +71 -0
- basic_memory/mcp/clients/search.py +65 -0
- basic_memory/mcp/container.py +110 -0
- basic_memory/mcp/project_context.py +155 -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 +61 -9
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +249 -0
- basic_memory/mcp/tools/edit_note.py +325 -0
- basic_memory/mcp/tools/list_directory.py +157 -0
- basic_memory/mcp/tools/move_note.py +549 -0
- basic_memory/mcp/tools/project_management.py +204 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +265 -0
- basic_memory/mcp/tools/recent_activity.py +528 -0
- basic_memory/mcp/tools/search.py +377 -24
- basic_memory/mcp/tools/utils.py +402 -16
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +82 -17
- basic_memory/models/project.py +93 -0
- basic_memory/models/search.py +68 -8
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +437 -8
- basic_memory/repository/observation_repository.py +36 -3
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +140 -0
- basic_memory/repository/relation_repository.py +79 -4
- basic_memory/repository/repository.py +148 -29
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +79 -268
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +437 -0
- basic_memory/runtime.py +61 -0
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +131 -12
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +31 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +194 -25
- basic_memory/schemas/project_info.py +213 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/response.py +85 -28
- basic_memory/schemas/search.py +36 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +133 -0
- basic_memory/schemas/v2/resource.py +47 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +451 -138
- basic_memory/services/directory_service.py +310 -0
- basic_memory/services/entity_service.py +636 -71
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +402 -33
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +888 -0
- basic_memory/services/search_service.py +232 -37
- basic_memory/sync/__init__.py +4 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +1200 -109
- basic_memory/sync/watch_service.py +432 -135
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +407 -54
- basic_memory-0.17.4.dist-info/METADATA +617 -0
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.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.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""V2 Project Router - External ID-based project management operations.
|
|
2
|
+
|
|
3
|
+
This router provides external_id (UUID) based CRUD operations for projects,
|
|
4
|
+
using stable string UUIDs that never change (unlike integer IDs or names).
|
|
5
|
+
|
|
6
|
+
Key improvements:
|
|
7
|
+
- Stable external UUIDs that won't change with renames or database migrations
|
|
8
|
+
- Better API ergonomics with consistent string identifiers
|
|
9
|
+
- Direct database lookups via unique indexed column
|
|
10
|
+
- Consistent with v2 entity operations
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException, Body, Query, Path
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
from basic_memory.deps import (
|
|
20
|
+
ProjectServiceDep,
|
|
21
|
+
ProjectRepositoryDep,
|
|
22
|
+
)
|
|
23
|
+
from basic_memory.schemas.project_info import (
|
|
24
|
+
ProjectItem,
|
|
25
|
+
ProjectStatusResponse,
|
|
26
|
+
)
|
|
27
|
+
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
|
|
28
|
+
from basic_memory.utils import normalize_project_path, generate_permalink
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/resolve", response_model=ProjectResolveResponse)
|
|
34
|
+
async def resolve_project_identifier(
|
|
35
|
+
data: ProjectResolveRequest,
|
|
36
|
+
project_repository: ProjectRepositoryDep,
|
|
37
|
+
) -> ProjectResolveResponse:
|
|
38
|
+
"""Resolve a project identifier (name, permalink, or external_id) to project info.
|
|
39
|
+
|
|
40
|
+
This endpoint provides efficient lookup of projects by various identifiers
|
|
41
|
+
without needing to fetch the entire project list. Supports:
|
|
42
|
+
- External ID (UUID string) - preferred stable identifier
|
|
43
|
+
- Permalink
|
|
44
|
+
- Case-insensitive name matching
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
data: Request containing the identifier to resolve
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Project information including the external_id (UUID)
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
HTTPException: 404 if project not found
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
POST /v2/projects/resolve
|
|
57
|
+
{"identifier": "my-project"}
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
{
|
|
61
|
+
"external_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
62
|
+
"project_id": 1,
|
|
63
|
+
"name": "my-project",
|
|
64
|
+
"permalink": "my-project",
|
|
65
|
+
"path": "/path/to/project",
|
|
66
|
+
"is_active": true,
|
|
67
|
+
"is_default": false,
|
|
68
|
+
"resolution_method": "name"
|
|
69
|
+
}
|
|
70
|
+
"""
|
|
71
|
+
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
|
|
72
|
+
|
|
73
|
+
# Generate permalink for comparison
|
|
74
|
+
identifier_permalink = generate_permalink(data.identifier)
|
|
75
|
+
|
|
76
|
+
resolution_method = "name"
|
|
77
|
+
project = None
|
|
78
|
+
|
|
79
|
+
# Try external_id first (UUID format)
|
|
80
|
+
project = await project_repository.get_by_external_id(data.identifier)
|
|
81
|
+
if project:
|
|
82
|
+
resolution_method = "external_id"
|
|
83
|
+
|
|
84
|
+
# If not found by external_id, try by permalink (exact match)
|
|
85
|
+
if not project:
|
|
86
|
+
project = await project_repository.get_by_permalink(identifier_permalink)
|
|
87
|
+
if project:
|
|
88
|
+
resolution_method = "permalink"
|
|
89
|
+
|
|
90
|
+
# If not found by permalink, try case-insensitive name search
|
|
91
|
+
if not project:
|
|
92
|
+
project = await project_repository.get_by_name_case_insensitive(data.identifier)
|
|
93
|
+
if project:
|
|
94
|
+
resolution_method = "name" # pragma: no cover
|
|
95
|
+
|
|
96
|
+
if not project:
|
|
97
|
+
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
|
|
98
|
+
|
|
99
|
+
return ProjectResolveResponse(
|
|
100
|
+
external_id=project.external_id,
|
|
101
|
+
project_id=project.id,
|
|
102
|
+
name=project.name,
|
|
103
|
+
permalink=generate_permalink(project.name),
|
|
104
|
+
path=normalize_project_path(project.path),
|
|
105
|
+
is_active=project.is_active if hasattr(project, "is_active") else True,
|
|
106
|
+
is_default=project.is_default or False,
|
|
107
|
+
resolution_method=resolution_method,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/{project_id}", response_model=ProjectItem)
|
|
112
|
+
async def get_project_by_id(
|
|
113
|
+
project_repository: ProjectRepositoryDep,
|
|
114
|
+
project_id: str = Path(..., description="Project external ID (UUID)"),
|
|
115
|
+
) -> ProjectItem:
|
|
116
|
+
"""Get project by its external ID (UUID).
|
|
117
|
+
|
|
118
|
+
This is the primary project retrieval method in v2, using stable UUID
|
|
119
|
+
identifiers that won't change with project renames.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
project_id: External ID (UUID string)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Project information including external_id
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
HTTPException: 404 if project not found
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
GET /v2/projects/550e8400-e29b-41d4-a716-446655440000
|
|
132
|
+
"""
|
|
133
|
+
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
|
|
134
|
+
|
|
135
|
+
project = await project_repository.get_by_external_id(project_id)
|
|
136
|
+
if not project:
|
|
137
|
+
raise HTTPException(
|
|
138
|
+
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return ProjectItem(
|
|
142
|
+
id=project.id,
|
|
143
|
+
external_id=project.external_id,
|
|
144
|
+
name=project.name,
|
|
145
|
+
path=normalize_project_path(project.path),
|
|
146
|
+
is_default=project.is_default or False,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
|
151
|
+
async def update_project_by_id(
|
|
152
|
+
project_service: ProjectServiceDep,
|
|
153
|
+
project_repository: ProjectRepositoryDep,
|
|
154
|
+
project_id: str = Path(..., description="Project external ID (UUID)"),
|
|
155
|
+
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
|
156
|
+
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
|
157
|
+
) -> ProjectStatusResponse:
|
|
158
|
+
"""Update a project's information by external ID.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
project_id: External ID (UUID string)
|
|
162
|
+
path: Optional new absolute path for the project
|
|
163
|
+
is_active: Optional status update for the project
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Response confirming the project was updated
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
HTTPException: 400 if validation fails, 404 if project not found
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
PATCH /v2/projects/550e8400-e29b-41d4-a716-446655440000
|
|
173
|
+
{"path": "/new/path"}
|
|
174
|
+
"""
|
|
175
|
+
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
# Validate that path is absolute if provided
|
|
179
|
+
if path and not os.path.isabs(path):
|
|
180
|
+
raise HTTPException(status_code=400, detail="Path must be absolute")
|
|
181
|
+
|
|
182
|
+
# Get original project info for the response
|
|
183
|
+
old_project = await project_repository.get_by_external_id(project_id)
|
|
184
|
+
if not old_project:
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
old_project_info = ProjectItem(
|
|
190
|
+
id=old_project.id,
|
|
191
|
+
external_id=old_project.external_id,
|
|
192
|
+
name=old_project.name,
|
|
193
|
+
path=old_project.path,
|
|
194
|
+
is_default=old_project.is_default or False,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Update using project name (service layer still uses names internally)
|
|
198
|
+
if path:
|
|
199
|
+
await project_service.move_project(old_project.name, path)
|
|
200
|
+
elif is_active is not None:
|
|
201
|
+
await project_service.update_project(old_project.name, is_active=is_active)
|
|
202
|
+
|
|
203
|
+
# Get updated project info (use the same external_id)
|
|
204
|
+
updated_project = await project_repository.get_by_external_id(project_id)
|
|
205
|
+
if not updated_project: # pragma: no cover
|
|
206
|
+
raise HTTPException(
|
|
207
|
+
status_code=404,
|
|
208
|
+
detail=f"Project with external_id '{project_id}' not found after update",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return ProjectStatusResponse(
|
|
212
|
+
message=f"Project '{updated_project.name}' updated successfully",
|
|
213
|
+
status="success",
|
|
214
|
+
default=old_project.is_default or False,
|
|
215
|
+
old_project=old_project_info,
|
|
216
|
+
new_project=ProjectItem(
|
|
217
|
+
id=updated_project.id,
|
|
218
|
+
external_id=updated_project.external_id,
|
|
219
|
+
name=updated_project.name,
|
|
220
|
+
path=updated_project.path,
|
|
221
|
+
is_default=updated_project.is_default or False,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
except ValueError as e: # pragma: no cover
|
|
225
|
+
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
|
|
229
|
+
async def delete_project_by_id(
|
|
230
|
+
project_service: ProjectServiceDep,
|
|
231
|
+
project_repository: ProjectRepositoryDep,
|
|
232
|
+
project_id: str = Path(..., description="Project external ID (UUID)"),
|
|
233
|
+
delete_notes: bool = Query(
|
|
234
|
+
False, description="If True, delete project directory from filesystem"
|
|
235
|
+
),
|
|
236
|
+
) -> ProjectStatusResponse:
|
|
237
|
+
"""Delete a project by external ID.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
project_id: External ID (UUID string)
|
|
241
|
+
delete_notes: If True, delete the project directory from the filesystem
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Response confirming the project was deleted
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
HTTPException: 400 if trying to delete default project, 404 if not found
|
|
248
|
+
|
|
249
|
+
Example:
|
|
250
|
+
DELETE /v2/projects/550e8400-e29b-41d4-a716-446655440000?delete_notes=false
|
|
251
|
+
"""
|
|
252
|
+
logger.info(
|
|
253
|
+
f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
old_project = await project_repository.get_by_external_id(project_id)
|
|
258
|
+
if not old_project:
|
|
259
|
+
raise HTTPException(
|
|
260
|
+
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Check if trying to delete the default project
|
|
264
|
+
# Use is_default from database, not ConfigManager (which doesn't work in cloud mode)
|
|
265
|
+
if old_project.is_default:
|
|
266
|
+
available_projects = await project_service.list_projects()
|
|
267
|
+
other_projects = [
|
|
268
|
+
p.name for p in available_projects if p.external_id != project_id
|
|
269
|
+
]
|
|
270
|
+
detail = f"Cannot delete default project '{old_project.name}'. "
|
|
271
|
+
if other_projects:
|
|
272
|
+
detail += ( # pragma: no cover
|
|
273
|
+
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
detail += "This is the only project in your configuration." # pragma: no cover
|
|
277
|
+
raise HTTPException(status_code=400, detail=detail)
|
|
278
|
+
|
|
279
|
+
# Delete using project name (service layer still uses names internally)
|
|
280
|
+
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
|
|
281
|
+
|
|
282
|
+
return ProjectStatusResponse(
|
|
283
|
+
message=f"Project '{old_project.name}' removed successfully",
|
|
284
|
+
status="success",
|
|
285
|
+
default=False,
|
|
286
|
+
old_project=ProjectItem(
|
|
287
|
+
id=old_project.id,
|
|
288
|
+
external_id=old_project.external_id,
|
|
289
|
+
name=old_project.name,
|
|
290
|
+
path=old_project.path,
|
|
291
|
+
is_default=old_project.is_default or False,
|
|
292
|
+
),
|
|
293
|
+
new_project=None,
|
|
294
|
+
)
|
|
295
|
+
except ValueError as e: # pragma: no cover
|
|
296
|
+
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
|
|
300
|
+
async def set_default_project_by_id(
|
|
301
|
+
project_service: ProjectServiceDep,
|
|
302
|
+
project_repository: ProjectRepositoryDep,
|
|
303
|
+
project_id: str = Path(..., description="Project external ID (UUID)"),
|
|
304
|
+
) -> ProjectStatusResponse:
|
|
305
|
+
"""Set a project as the default project by external ID.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
project_id: External ID (UUID string) to set as default
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Response confirming the project was set as default
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
HTTPException: 404 if project not found
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
PUT /v2/projects/550e8400-e29b-41d4-a716-446655440000/default
|
|
318
|
+
"""
|
|
319
|
+
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
# Get the old default project from database
|
|
323
|
+
default_project = await project_repository.get_default_project()
|
|
324
|
+
if not default_project:
|
|
325
|
+
raise HTTPException( # pragma: no cover
|
|
326
|
+
status_code=404, detail="No default project is currently set"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Get the new default project by external_id
|
|
330
|
+
new_default_project = await project_repository.get_by_external_id(project_id)
|
|
331
|
+
if not new_default_project:
|
|
332
|
+
raise HTTPException(
|
|
333
|
+
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Set as default using project name (service layer still uses names internally)
|
|
337
|
+
await project_service.set_default_project(new_default_project.name)
|
|
338
|
+
|
|
339
|
+
return ProjectStatusResponse(
|
|
340
|
+
message=f"Project '{new_default_project.name}' set as default successfully",
|
|
341
|
+
status="success",
|
|
342
|
+
default=True,
|
|
343
|
+
old_project=ProjectItem(
|
|
344
|
+
id=default_project.id,
|
|
345
|
+
external_id=default_project.external_id,
|
|
346
|
+
name=default_project.name,
|
|
347
|
+
path=default_project.path,
|
|
348
|
+
is_default=False,
|
|
349
|
+
),
|
|
350
|
+
new_project=ProjectItem(
|
|
351
|
+
id=new_default_project.id,
|
|
352
|
+
external_id=new_default_project.external_id,
|
|
353
|
+
name=new_default_project.name,
|
|
354
|
+
path=new_default_project.path,
|
|
355
|
+
is_default=True,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
except ValueError as e: # pragma: no cover
|
|
359
|
+
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""V2 Prompt Router - ID-based prompt generation operations.
|
|
2
|
+
|
|
3
|
+
This router uses v2 dependencies for consistent project handling with external_id UUIDs.
|
|
4
|
+
Prompt endpoints are action-based (not resource-based), so they don't
|
|
5
|
+
have entity IDs in URLs - they generate formatted prompts from queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from fastapi import APIRouter, HTTPException, status, Path
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
|
13
|
+
from basic_memory.api.template_loader import template_loader
|
|
14
|
+
from basic_memory.schemas.base import parse_timeframe
|
|
15
|
+
from basic_memory.deps import (
|
|
16
|
+
ContextServiceV2ExternalDep,
|
|
17
|
+
EntityRepositoryV2ExternalDep,
|
|
18
|
+
SearchServiceV2ExternalDep,
|
|
19
|
+
EntityServiceV2ExternalDep,
|
|
20
|
+
)
|
|
21
|
+
from basic_memory.schemas.prompt import (
|
|
22
|
+
ContinueConversationRequest,
|
|
23
|
+
SearchPromptRequest,
|
|
24
|
+
PromptResponse,
|
|
25
|
+
PromptMetadata,
|
|
26
|
+
)
|
|
27
|
+
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/continue-conversation", response_model=PromptResponse)
|
|
33
|
+
async def continue_conversation(
|
|
34
|
+
search_service: SearchServiceV2ExternalDep,
|
|
35
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
36
|
+
context_service: ContextServiceV2ExternalDep,
|
|
37
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
38
|
+
request: ContinueConversationRequest,
|
|
39
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
40
|
+
) -> PromptResponse:
|
|
41
|
+
"""Generate a prompt for continuing a conversation.
|
|
42
|
+
|
|
43
|
+
This endpoint takes a topic and/or timeframe and generates a prompt with
|
|
44
|
+
relevant context from the knowledge base.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
project_id: Project external UUID from URL path
|
|
48
|
+
request: The request parameters
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Formatted continuation prompt with context
|
|
52
|
+
"""
|
|
53
|
+
logger.info(
|
|
54
|
+
f"V2 Generating continue conversation prompt for project {project_id}, "
|
|
55
|
+
f"topic: {request.topic}, timeframe: {request.timeframe}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
|
59
|
+
|
|
60
|
+
# Initialize search results
|
|
61
|
+
search_results = []
|
|
62
|
+
|
|
63
|
+
# Get data needed for template
|
|
64
|
+
if request.topic:
|
|
65
|
+
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
|
66
|
+
results = await search_service.search(query, limit=request.search_items_limit)
|
|
67
|
+
search_results = await to_search_results(entity_service, results)
|
|
68
|
+
|
|
69
|
+
# Build context from results
|
|
70
|
+
all_hierarchical_results = []
|
|
71
|
+
for result in search_results:
|
|
72
|
+
if hasattr(result, "permalink") and result.permalink:
|
|
73
|
+
# Get hierarchical context using the new dataclass-based approach
|
|
74
|
+
context_result = await context_service.build_context(
|
|
75
|
+
result.permalink,
|
|
76
|
+
depth=request.depth,
|
|
77
|
+
since=since,
|
|
78
|
+
max_related=request.related_items_limit,
|
|
79
|
+
include_observations=True, # Include observations for entities
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Process results into the schema format
|
|
83
|
+
graph_context = await to_graph_context(
|
|
84
|
+
context_result, entity_repository=entity_repository
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Add results to our collection (limit to top results for each permalink)
|
|
88
|
+
if graph_context.results:
|
|
89
|
+
all_hierarchical_results.extend(graph_context.results[:3])
|
|
90
|
+
|
|
91
|
+
# Limit to a reasonable number of total results
|
|
92
|
+
all_hierarchical_results = all_hierarchical_results[:10]
|
|
93
|
+
|
|
94
|
+
template_context = {
|
|
95
|
+
"topic": request.topic,
|
|
96
|
+
"timeframe": request.timeframe,
|
|
97
|
+
"hierarchical_results": all_hierarchical_results,
|
|
98
|
+
"has_results": len(all_hierarchical_results) > 0,
|
|
99
|
+
}
|
|
100
|
+
else:
|
|
101
|
+
# If no topic, get recent activity
|
|
102
|
+
context_result = await context_service.build_context(
|
|
103
|
+
types=[SearchItemType.ENTITY],
|
|
104
|
+
depth=request.depth,
|
|
105
|
+
since=since,
|
|
106
|
+
max_related=request.related_items_limit,
|
|
107
|
+
include_observations=True,
|
|
108
|
+
)
|
|
109
|
+
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
|
110
|
+
|
|
111
|
+
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
|
112
|
+
|
|
113
|
+
template_context = {
|
|
114
|
+
"topic": f"Recent Activity from ({request.timeframe})",
|
|
115
|
+
"timeframe": request.timeframe,
|
|
116
|
+
"hierarchical_results": hierarchical_results,
|
|
117
|
+
"has_results": len(hierarchical_results) > 0,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Render template
|
|
122
|
+
rendered_prompt = await template_loader.render(
|
|
123
|
+
"prompts/continue_conversation.hbs", template_context
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Calculate metadata
|
|
127
|
+
# Count items of different types
|
|
128
|
+
observation_count = 0
|
|
129
|
+
relation_count = 0
|
|
130
|
+
entity_count = 0
|
|
131
|
+
|
|
132
|
+
# Get the hierarchical results from the template context
|
|
133
|
+
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
|
134
|
+
|
|
135
|
+
# For topic-based search
|
|
136
|
+
if request.topic:
|
|
137
|
+
for item in hierarchical_results_for_count:
|
|
138
|
+
if hasattr(item, "observations"):
|
|
139
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
140
|
+
|
|
141
|
+
if hasattr(item, "related_results"):
|
|
142
|
+
for related in item.related_results or []:
|
|
143
|
+
if hasattr(related, "type"):
|
|
144
|
+
if related.type == "relation":
|
|
145
|
+
relation_count += 1
|
|
146
|
+
elif related.type == "entity": # pragma: no cover
|
|
147
|
+
entity_count += 1 # pragma: no cover
|
|
148
|
+
# For recent activity
|
|
149
|
+
else:
|
|
150
|
+
for item in hierarchical_results_for_count:
|
|
151
|
+
if hasattr(item, "observations"):
|
|
152
|
+
observation_count += len(item.observations) if item.observations else 0
|
|
153
|
+
|
|
154
|
+
if hasattr(item, "related_results"):
|
|
155
|
+
for related in item.related_results or []:
|
|
156
|
+
if hasattr(related, "type"):
|
|
157
|
+
if related.type == "relation":
|
|
158
|
+
relation_count += 1
|
|
159
|
+
elif related.type == "entity": # pragma: no cover
|
|
160
|
+
entity_count += 1 # pragma: no cover
|
|
161
|
+
|
|
162
|
+
# Build metadata
|
|
163
|
+
metadata = {
|
|
164
|
+
"query": request.topic,
|
|
165
|
+
"timeframe": request.timeframe,
|
|
166
|
+
"search_count": len(search_results)
|
|
167
|
+
if request.topic
|
|
168
|
+
else 0, # Original search results count
|
|
169
|
+
"context_count": len(hierarchical_results_for_count),
|
|
170
|
+
"observation_count": observation_count,
|
|
171
|
+
"relation_count": relation_count,
|
|
172
|
+
"total_items": (
|
|
173
|
+
len(hierarchical_results_for_count)
|
|
174
|
+
+ observation_count
|
|
175
|
+
+ relation_count
|
|
176
|
+
+ entity_count
|
|
177
|
+
),
|
|
178
|
+
"search_limit": request.search_items_limit,
|
|
179
|
+
"context_depth": request.depth,
|
|
180
|
+
"related_limit": request.related_items_limit,
|
|
181
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
185
|
+
|
|
186
|
+
return PromptResponse(
|
|
187
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Error rendering continue conversation template: {e}")
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
193
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.post("/search", response_model=PromptResponse)
|
|
198
|
+
async def search_prompt(
|
|
199
|
+
search_service: SearchServiceV2ExternalDep,
|
|
200
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
201
|
+
request: SearchPromptRequest,
|
|
202
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
203
|
+
page: int = 1,
|
|
204
|
+
page_size: int = 10,
|
|
205
|
+
) -> PromptResponse:
|
|
206
|
+
"""Generate a prompt for search results.
|
|
207
|
+
|
|
208
|
+
This endpoint takes a search query and formats the results into a helpful
|
|
209
|
+
prompt with context and suggestions.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
project_id: Project external UUID from URL path
|
|
213
|
+
request: The search parameters
|
|
214
|
+
page: The page number for pagination
|
|
215
|
+
page_size: The number of results per page, defaults to 10
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Formatted search results prompt with context
|
|
219
|
+
"""
|
|
220
|
+
logger.info(
|
|
221
|
+
f"V2 Generating search prompt for project {project_id}, "
|
|
222
|
+
f"query: {request.query}, timeframe: {request.timeframe}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
limit = page_size
|
|
226
|
+
offset = (page - 1) * page_size
|
|
227
|
+
|
|
228
|
+
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
|
229
|
+
results = await search_service.search(query, limit=limit, offset=offset)
|
|
230
|
+
search_results = await to_search_results(entity_service, results)
|
|
231
|
+
|
|
232
|
+
template_context = {
|
|
233
|
+
"query": request.query,
|
|
234
|
+
"timeframe": request.timeframe,
|
|
235
|
+
"results": search_results,
|
|
236
|
+
"has_results": len(search_results) > 0,
|
|
237
|
+
"result_count": len(search_results),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
# Render template
|
|
242
|
+
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
|
243
|
+
|
|
244
|
+
# Build metadata
|
|
245
|
+
metadata = {
|
|
246
|
+
"query": request.query,
|
|
247
|
+
"timeframe": request.timeframe,
|
|
248
|
+
"search_count": len(search_results),
|
|
249
|
+
"context_count": len(search_results),
|
|
250
|
+
"observation_count": 0, # Search results don't include observations
|
|
251
|
+
"relation_count": 0, # Search results don't include relations
|
|
252
|
+
"total_items": len(search_results),
|
|
253
|
+
"search_limit": limit,
|
|
254
|
+
"context_depth": 0, # No context depth for basic search
|
|
255
|
+
"related_limit": 0, # No related items for basic search
|
|
256
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
prompt_metadata = PromptMetadata(**metadata)
|
|
260
|
+
|
|
261
|
+
return PromptResponse(
|
|
262
|
+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logger.error(f"Error rendering search template: {e}")
|
|
266
|
+
raise HTTPException(
|
|
267
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
268
|
+
detail=f"Error rendering prompt template: {str(e)}",
|
|
269
|
+
)
|