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,427 @@
|
|
|
1
|
+
"""V2 Knowledge Router - External ID-based entity operations.
|
|
2
|
+
|
|
3
|
+
This router provides external_id (UUID) based CRUD operations for entities,
|
|
4
|
+
using stable string UUIDs that won't change with file moves or database migrations.
|
|
5
|
+
|
|
6
|
+
Key improvements:
|
|
7
|
+
- Stable external UUIDs that won't change with file moves or renames
|
|
8
|
+
- Better API ergonomics with consistent string identifiers
|
|
9
|
+
- Direct database lookups via unique indexed column
|
|
10
|
+
- Simplified caching strategies
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from basic_memory.deps import (
|
|
17
|
+
EntityServiceV2ExternalDep,
|
|
18
|
+
SearchServiceV2ExternalDep,
|
|
19
|
+
LinkResolverV2ExternalDep,
|
|
20
|
+
ProjectConfigV2ExternalDep,
|
|
21
|
+
AppConfigDep,
|
|
22
|
+
SyncServiceV2ExternalDep,
|
|
23
|
+
EntityRepositoryV2ExternalDep,
|
|
24
|
+
ProjectExternalIdPathDep,
|
|
25
|
+
)
|
|
26
|
+
from basic_memory.schemas import DeleteEntitiesResponse
|
|
27
|
+
from basic_memory.schemas.base import Entity
|
|
28
|
+
from basic_memory.schemas.request import EditEntityRequest
|
|
29
|
+
from basic_memory.schemas.v2 import (
|
|
30
|
+
EntityResolveRequest,
|
|
31
|
+
EntityResolveResponse,
|
|
32
|
+
EntityResponseV2,
|
|
33
|
+
MoveEntityRequestV2,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
|
40
|
+
"""Background task to resolve relations for a specific entity.
|
|
41
|
+
|
|
42
|
+
This runs asynchronously after the API response is sent, preventing
|
|
43
|
+
long delays when creating entities with many relations.
|
|
44
|
+
"""
|
|
45
|
+
try: # pragma: no cover
|
|
46
|
+
# Only resolve relations for the newly created entity
|
|
47
|
+
await sync_service.resolve_relations(entity_id=entity_id) # pragma: no cover
|
|
48
|
+
logger.debug( # pragma: no cover
|
|
49
|
+
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
|
50
|
+
)
|
|
51
|
+
except Exception as e: # pragma: no cover
|
|
52
|
+
# Log but don't fail - this is a background task
|
|
53
|
+
logger.warning( # pragma: no cover
|
|
54
|
+
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Resolution endpoint
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/resolve", response_model=EntityResolveResponse)
|
|
62
|
+
async def resolve_identifier(
|
|
63
|
+
project_id: ProjectExternalIdPathDep,
|
|
64
|
+
data: EntityResolveRequest,
|
|
65
|
+
link_resolver: LinkResolverV2ExternalDep,
|
|
66
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
67
|
+
) -> EntityResolveResponse:
|
|
68
|
+
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
|
|
69
|
+
|
|
70
|
+
This endpoint provides a bridge between v1-style identifiers and v2 external_ids.
|
|
71
|
+
Use this to convert existing references to the new UUID-based format.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data: Request containing the identifier to resolve
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Entity external_id and metadata about how it was resolved
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HTTPException: 404 if identifier cannot be resolved
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
POST /v2/{project_id}/knowledge/resolve
|
|
84
|
+
{"identifier": "specs/search"}
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
{
|
|
88
|
+
"external_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
89
|
+
"entity_id": 123,
|
|
90
|
+
"permalink": "specs/search",
|
|
91
|
+
"file_path": "specs/search.md",
|
|
92
|
+
"title": "Search Specification",
|
|
93
|
+
"resolution_method": "permalink"
|
|
94
|
+
}
|
|
95
|
+
"""
|
|
96
|
+
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
|
|
97
|
+
|
|
98
|
+
# Try to resolve by external_id first
|
|
99
|
+
entity = await entity_repository.get_by_external_id(data.identifier)
|
|
100
|
+
resolution_method = "external_id" if entity else "search"
|
|
101
|
+
|
|
102
|
+
# If not found by external_id, try other resolution methods
|
|
103
|
+
if not entity:
|
|
104
|
+
entity = await link_resolver.resolve_link(data.identifier)
|
|
105
|
+
if entity:
|
|
106
|
+
# Determine resolution method
|
|
107
|
+
if entity.permalink == data.identifier:
|
|
108
|
+
resolution_method = "permalink"
|
|
109
|
+
elif entity.title == data.identifier:
|
|
110
|
+
resolution_method = "title"
|
|
111
|
+
elif entity.file_path == data.identifier:
|
|
112
|
+
resolution_method = "path"
|
|
113
|
+
else:
|
|
114
|
+
resolution_method = "search"
|
|
115
|
+
|
|
116
|
+
if not entity:
|
|
117
|
+
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
|
118
|
+
|
|
119
|
+
result = EntityResolveResponse(
|
|
120
|
+
external_id=entity.external_id,
|
|
121
|
+
entity_id=entity.id,
|
|
122
|
+
permalink=entity.permalink,
|
|
123
|
+
file_path=entity.file_path,
|
|
124
|
+
title=entity.title,
|
|
125
|
+
resolution_method=resolution_method,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
f"API v2 response: resolved '{data.identifier}' to external_id={result.external_id} via {resolution_method}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
## Read endpoints
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
|
|
139
|
+
async def get_entity_by_id(
|
|
140
|
+
project_id: ProjectExternalIdPathDep,
|
|
141
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
142
|
+
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
|
143
|
+
) -> EntityResponseV2:
|
|
144
|
+
"""Get an entity by its external ID (UUID).
|
|
145
|
+
|
|
146
|
+
This is the primary entity retrieval method in v2, using stable UUID
|
|
147
|
+
identifiers that won't change with file moves.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
entity_id: External ID (UUID string)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Complete entity with observations and relations
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
HTTPException: 404 if entity not found
|
|
157
|
+
"""
|
|
158
|
+
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
|
159
|
+
|
|
160
|
+
entity = await entity_repository.get_by_external_id(entity_id)
|
|
161
|
+
if not entity:
|
|
162
|
+
raise HTTPException(
|
|
163
|
+
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
result = EntityResponseV2.model_validate(entity)
|
|
167
|
+
logger.info(f"API v2 response: external_id={entity_id}, title='{result.title}'")
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
## Create endpoints
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.post("/entities", response_model=EntityResponseV2)
|
|
176
|
+
async def create_entity(
|
|
177
|
+
project_id: ProjectExternalIdPathDep,
|
|
178
|
+
data: Entity,
|
|
179
|
+
background_tasks: BackgroundTasks,
|
|
180
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
181
|
+
search_service: SearchServiceV2ExternalDep,
|
|
182
|
+
) -> EntityResponseV2:
|
|
183
|
+
"""Create a new entity.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
data: Entity data to create
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Created entity with generated external_id (UUID)
|
|
190
|
+
"""
|
|
191
|
+
logger.info(
|
|
192
|
+
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
entity = await entity_service.create_entity(data)
|
|
196
|
+
|
|
197
|
+
# reindex
|
|
198
|
+
await search_service.index_entity(entity, background_tasks=background_tasks)
|
|
199
|
+
result = EntityResponseV2.model_validate(entity)
|
|
200
|
+
|
|
201
|
+
logger.info(
|
|
202
|
+
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
|
203
|
+
)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
## Update endpoints
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.put("/entities/{entity_id}", response_model=EntityResponseV2)
|
|
211
|
+
async def update_entity_by_id(
|
|
212
|
+
data: Entity,
|
|
213
|
+
response: Response,
|
|
214
|
+
background_tasks: BackgroundTasks,
|
|
215
|
+
project_id: ProjectExternalIdPathDep,
|
|
216
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
217
|
+
search_service: SearchServiceV2ExternalDep,
|
|
218
|
+
sync_service: SyncServiceV2ExternalDep,
|
|
219
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
220
|
+
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
|
221
|
+
) -> EntityResponseV2:
|
|
222
|
+
"""Update an entity by external ID.
|
|
223
|
+
|
|
224
|
+
If the entity doesn't exist, it will be created (upsert behavior).
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
entity_id: External ID (UUID string)
|
|
228
|
+
data: Updated entity data
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Updated entity
|
|
232
|
+
"""
|
|
233
|
+
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
|
234
|
+
|
|
235
|
+
# Check if entity exists
|
|
236
|
+
existing = await entity_repository.get_by_external_id(entity_id)
|
|
237
|
+
created = existing is None
|
|
238
|
+
|
|
239
|
+
# Perform update or create
|
|
240
|
+
entity, _ = await entity_service.create_or_update_entity(data)
|
|
241
|
+
response.status_code = 201 if created else 200
|
|
242
|
+
|
|
243
|
+
# reindex
|
|
244
|
+
await search_service.index_entity(entity, background_tasks=background_tasks)
|
|
245
|
+
|
|
246
|
+
# Schedule relation resolution for new entities
|
|
247
|
+
if created:
|
|
248
|
+
background_tasks.add_task( # pragma: no cover
|
|
249
|
+
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
result = EntityResponseV2.model_validate(entity)
|
|
253
|
+
|
|
254
|
+
logger.info(
|
|
255
|
+
f"API v2 response: external_id={entity_id}, created={created}, status_code={response.status_code}"
|
|
256
|
+
)
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
|
|
261
|
+
async def edit_entity_by_id(
|
|
262
|
+
data: EditEntityRequest,
|
|
263
|
+
background_tasks: BackgroundTasks,
|
|
264
|
+
project_id: ProjectExternalIdPathDep,
|
|
265
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
266
|
+
search_service: SearchServiceV2ExternalDep,
|
|
267
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
268
|
+
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
|
269
|
+
) -> EntityResponseV2:
|
|
270
|
+
"""Edit an existing entity by external ID using operations like append, prepend, etc.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
entity_id: External ID (UUID string)
|
|
274
|
+
data: Edit operation details
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Updated entity
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
HTTPException: 404 if entity not found, 400 if edit fails
|
|
281
|
+
"""
|
|
282
|
+
logger.info(
|
|
283
|
+
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Verify entity exists
|
|
287
|
+
entity = await entity_repository.get_by_external_id(entity_id)
|
|
288
|
+
if not entity: # pragma: no cover
|
|
289
|
+
raise HTTPException(
|
|
290
|
+
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
# Edit using the entity's permalink or path
|
|
295
|
+
identifier = entity.permalink or entity.file_path
|
|
296
|
+
updated_entity = await entity_service.edit_entity(
|
|
297
|
+
identifier=identifier,
|
|
298
|
+
operation=data.operation,
|
|
299
|
+
content=data.content,
|
|
300
|
+
section=data.section,
|
|
301
|
+
find_text=data.find_text,
|
|
302
|
+
expected_replacements=data.expected_replacements,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Reindex
|
|
306
|
+
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
|
307
|
+
|
|
308
|
+
result = EntityResponseV2.model_validate(updated_entity)
|
|
309
|
+
|
|
310
|
+
logger.info(
|
|
311
|
+
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Error editing entity {entity_id}: {e}")
|
|
318
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
## Delete endpoints
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
|
325
|
+
async def delete_entity_by_id(
|
|
326
|
+
background_tasks: BackgroundTasks,
|
|
327
|
+
project_id: ProjectExternalIdPathDep,
|
|
328
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
329
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
330
|
+
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
|
331
|
+
search_service=Depends(lambda: None), # Optional for now
|
|
332
|
+
) -> DeleteEntitiesResponse:
|
|
333
|
+
"""Delete an entity by external ID.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
entity_id: External ID (UUID string)
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Deletion status
|
|
340
|
+
|
|
341
|
+
Note: Returns deleted=False if entity doesn't exist (idempotent)
|
|
342
|
+
"""
|
|
343
|
+
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
|
344
|
+
|
|
345
|
+
entity = await entity_repository.get_by_external_id(entity_id)
|
|
346
|
+
if entity is None:
|
|
347
|
+
logger.info(f"API v2 response: external_id={entity_id} not found, deleted=False")
|
|
348
|
+
return DeleteEntitiesResponse(deleted=False)
|
|
349
|
+
|
|
350
|
+
# Delete the entity using internal ID
|
|
351
|
+
deleted = await entity_service.delete_entity(entity.id)
|
|
352
|
+
|
|
353
|
+
# Remove from search index if search service available
|
|
354
|
+
if search_service:
|
|
355
|
+
background_tasks.add_task(search_service.handle_delete, entity) # pragma: no cover
|
|
356
|
+
|
|
357
|
+
logger.info(f"API v2 response: external_id={entity_id}, deleted={deleted}")
|
|
358
|
+
|
|
359
|
+
return DeleteEntitiesResponse(deleted=deleted)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
## Move endpoint
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
|
|
366
|
+
async def move_entity(
|
|
367
|
+
data: MoveEntityRequestV2,
|
|
368
|
+
background_tasks: BackgroundTasks,
|
|
369
|
+
project_id: ProjectExternalIdPathDep,
|
|
370
|
+
entity_service: EntityServiceV2ExternalDep,
|
|
371
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
372
|
+
project_config: ProjectConfigV2ExternalDep,
|
|
373
|
+
app_config: AppConfigDep,
|
|
374
|
+
search_service: SearchServiceV2ExternalDep,
|
|
375
|
+
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
|
376
|
+
) -> EntityResponseV2:
|
|
377
|
+
"""Move an entity to a new file location.
|
|
378
|
+
|
|
379
|
+
V2 API uses external_id (UUID) in the URL path for stable references.
|
|
380
|
+
The external_id will remain stable after the move.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
project_id: Project external ID from URL path
|
|
384
|
+
entity_id: Entity external ID from URL path (primary identifier)
|
|
385
|
+
data: Move request with destination path only
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Updated entity with new file path
|
|
389
|
+
"""
|
|
390
|
+
logger.info(
|
|
391
|
+
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
# First, get the entity by external_id to verify it exists
|
|
396
|
+
entity = await entity_repository.get_by_external_id(entity_id)
|
|
397
|
+
if not entity: # pragma: no cover
|
|
398
|
+
raise HTTPException(
|
|
399
|
+
status_code=404, detail=f"Entity with external_id '{entity_id}' not found"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Move the entity using its current file path as identifier
|
|
403
|
+
moved_entity = await entity_service.move_entity(
|
|
404
|
+
identifier=entity.file_path, # Use file path for resolution
|
|
405
|
+
destination_path=data.destination_path,
|
|
406
|
+
project_config=project_config,
|
|
407
|
+
app_config=app_config,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Reindex at new location
|
|
411
|
+
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
|
412
|
+
if reindexed_entity:
|
|
413
|
+
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
|
|
414
|
+
|
|
415
|
+
result = EntityResponseV2.model_validate(moved_entity)
|
|
416
|
+
|
|
417
|
+
logger.info(
|
|
418
|
+
f"API v2 response: moved external_id={entity_id} to '{data.destination_path}'"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return result
|
|
422
|
+
|
|
423
|
+
except HTTPException: # pragma: no cover
|
|
424
|
+
raise # pragma: no cover
|
|
425
|
+
except Exception as e:
|
|
426
|
+
logger.error(f"Error moving entity: {e}")
|
|
427
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""V2 routes for memory:// URI operations.
|
|
2
|
+
|
|
3
|
+
This router uses external_id UUIDs for stable, API-friendly routing.
|
|
4
|
+
V1 uses string-based project names which are less efficient and less stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Query, Path
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from basic_memory.deps import ContextServiceV2ExternalDep, EntityRepositoryV2ExternalDep
|
|
13
|
+
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
|
14
|
+
from basic_memory.schemas.memory import (
|
|
15
|
+
GraphContext,
|
|
16
|
+
normalize_memory_url,
|
|
17
|
+
)
|
|
18
|
+
from basic_memory.schemas.search import SearchItemType
|
|
19
|
+
from basic_memory.api.routers.utils import to_graph_context
|
|
20
|
+
|
|
21
|
+
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
|
|
22
|
+
router = APIRouter(tags=["memory"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/memory/recent", response_model=GraphContext)
|
|
26
|
+
async def recent(
|
|
27
|
+
context_service: ContextServiceV2ExternalDep,
|
|
28
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
29
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
30
|
+
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
|
31
|
+
depth: int = 1,
|
|
32
|
+
timeframe: TimeFrame = "7d",
|
|
33
|
+
page: int = 1,
|
|
34
|
+
page_size: int = 10,
|
|
35
|
+
max_related: int = 10,
|
|
36
|
+
) -> GraphContext:
|
|
37
|
+
"""Get recent activity context for a project.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
project_id: Project external UUID from URL path
|
|
41
|
+
context_service: Context service scoped to project
|
|
42
|
+
entity_repository: Entity repository scoped to project
|
|
43
|
+
type: Types of items to include (entities, relations, observations)
|
|
44
|
+
depth: How many levels of related entities to include
|
|
45
|
+
timeframe: Time window for recent activity (e.g., "7d", "1 week")
|
|
46
|
+
page: Page number for pagination
|
|
47
|
+
page_size: Number of items per page
|
|
48
|
+
max_related: Maximum related entities to include per item
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
GraphContext with recent activity and related entities
|
|
52
|
+
"""
|
|
53
|
+
# return all types by default
|
|
54
|
+
types = (
|
|
55
|
+
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
|
56
|
+
if not type
|
|
57
|
+
else type
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger.debug(
|
|
61
|
+
f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
|
62
|
+
)
|
|
63
|
+
# Parse timeframe
|
|
64
|
+
since = parse_timeframe(timeframe)
|
|
65
|
+
limit = page_size
|
|
66
|
+
offset = (page - 1) * page_size
|
|
67
|
+
|
|
68
|
+
# Build context
|
|
69
|
+
context = await context_service.build_context(
|
|
70
|
+
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
|
71
|
+
)
|
|
72
|
+
recent_context = await to_graph_context(
|
|
73
|
+
context, entity_repository=entity_repository, page=page, page_size=page_size
|
|
74
|
+
)
|
|
75
|
+
logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
|
|
76
|
+
return recent_context
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# get_memory_context needs to be declared last so other paths can match
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/memory/{uri:path}", response_model=GraphContext)
|
|
83
|
+
async def get_memory_context(
|
|
84
|
+
context_service: ContextServiceV2ExternalDep,
|
|
85
|
+
entity_repository: EntityRepositoryV2ExternalDep,
|
|
86
|
+
uri: str,
|
|
87
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
88
|
+
depth: int = 1,
|
|
89
|
+
timeframe: Optional[TimeFrame] = None,
|
|
90
|
+
page: int = 1,
|
|
91
|
+
page_size: int = 10,
|
|
92
|
+
max_related: int = 10,
|
|
93
|
+
) -> GraphContext:
|
|
94
|
+
"""Get rich context from memory:// URI.
|
|
95
|
+
|
|
96
|
+
V2 supports both legacy path-based URIs and new ID-based URIs:
|
|
97
|
+
- Legacy: memory://path/to/note
|
|
98
|
+
- ID-based: memory://id/123 or memory://123
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
project_id: Project external UUID from URL path
|
|
102
|
+
context_service: Context service scoped to project
|
|
103
|
+
entity_repository: Entity repository scoped to project
|
|
104
|
+
uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
|
|
105
|
+
depth: How many levels of related entities to include
|
|
106
|
+
timeframe: Optional time window for filtering related content
|
|
107
|
+
page: Page number for pagination
|
|
108
|
+
page_size: Number of items per page
|
|
109
|
+
max_related: Maximum related entities to include
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
GraphContext with the entity and its related context
|
|
113
|
+
"""
|
|
114
|
+
logger.debug(
|
|
115
|
+
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
|
116
|
+
)
|
|
117
|
+
memory_url = normalize_memory_url(uri)
|
|
118
|
+
|
|
119
|
+
# Parse timeframe
|
|
120
|
+
since = parse_timeframe(timeframe) if timeframe else None
|
|
121
|
+
limit = page_size
|
|
122
|
+
offset = (page - 1) * page_size
|
|
123
|
+
|
|
124
|
+
# Build context
|
|
125
|
+
context = await context_service.build_context(
|
|
126
|
+
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
|
127
|
+
)
|
|
128
|
+
return await to_graph_context(
|
|
129
|
+
context, entity_repository=entity_repository, page=page, page_size=page_size
|
|
130
|
+
)
|