basic-memory 0.16.1__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 +1 -1
- basic_memory/alembic/env.py +112 -26
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
- basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -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 +45 -24
- basic_memory/api/container.py +133 -0
- basic_memory/api/routers/knowledge_router.py +17 -5
- basic_memory/api/routers/project_router.py +68 -14
- basic_memory/api/routers/resource_router.py +37 -27
- basic_memory/api/routers/utils.py +53 -14
- 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 +43 -7
- basic_memory/cli/auth.py +27 -4
- basic_memory/cli/commands/__init__.py +3 -1
- basic_memory/cli/commands/cloud/api_client.py +20 -5
- basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
- basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
- basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
- basic_memory/cli/commands/cloud/upload.py +10 -3
- basic_memory/cli/commands/command_utils.py +52 -4
- basic_memory/cli/commands/db.py +78 -19
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +12 -8
- basic_memory/cli/commands/import_claude_conversations.py +12 -8
- basic_memory/cli/commands/import_claude_projects.py +12 -8
- basic_memory/cli/commands/import_memory_json.py +12 -8
- basic_memory/cli/commands/mcp.py +8 -26
- basic_memory/cli/commands/project.py +22 -9
- basic_memory/cli/commands/status.py +3 -2
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/container.py +84 -0
- basic_memory/cli/main.py +7 -0
- basic_memory/config.py +177 -77
- basic_memory/db.py +183 -77
- 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 +14 -409
- basic_memory/file_utils.py +212 -3
- basic_memory/ignore_utils.py +5 -5
- basic_memory/importers/base.py +40 -19
- basic_memory/importers/chatgpt_importer.py +17 -4
- basic_memory/importers/claude_conversations_importer.py +27 -12
- basic_memory/importers/claude_projects_importer.py +50 -14
- basic_memory/importers/memory_json_importer.py +36 -16
- basic_memory/importers/utils.py +5 -2
- basic_memory/markdown/entity_parser.py +62 -23
- basic_memory/markdown/markdown_processor.py +67 -4
- basic_memory/markdown/plugins.py +4 -2
- basic_memory/markdown/utils.py +10 -1
- basic_memory/mcp/async_client.py +1 -0
- 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 +47 -33
- basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
- basic_memory/mcp/prompts/recent_activity.py +2 -2
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/server.py +58 -0
- basic_memory/mcp/tools/build_context.py +14 -14
- basic_memory/mcp/tools/canvas.py +34 -12
- basic_memory/mcp/tools/chatgpt_tools.py +4 -1
- basic_memory/mcp/tools/delete_note.py +31 -7
- basic_memory/mcp/tools/edit_note.py +14 -9
- basic_memory/mcp/tools/list_directory.py +7 -17
- basic_memory/mcp/tools/move_note.py +35 -31
- basic_memory/mcp/tools/project_management.py +29 -25
- basic_memory/mcp/tools/read_content.py +13 -3
- basic_memory/mcp/tools/read_note.py +24 -14
- basic_memory/mcp/tools/recent_activity.py +32 -38
- basic_memory/mcp/tools/search.py +17 -10
- basic_memory/mcp/tools/utils.py +28 -0
- basic_memory/mcp/tools/view_note.py +2 -1
- basic_memory/mcp/tools/write_note.py +37 -14
- basic_memory/models/knowledge.py +15 -2
- basic_memory/models/project.py +7 -1
- basic_memory/models/search.py +58 -2
- basic_memory/project_resolver.py +222 -0
- basic_memory/repository/entity_repository.py +210 -3
- basic_memory/repository/observation_repository.py +1 -0
- basic_memory/repository/postgres_search_repository.py +451 -0
- basic_memory/repository/project_repository.py +38 -1
- basic_memory/repository/relation_repository.py +58 -2
- basic_memory/repository/repository.py +1 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +77 -615
- 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/base.py +36 -6
- basic_memory/schemas/directory.py +2 -1
- basic_memory/schemas/memory.py +9 -2
- basic_memory/schemas/project_info.py +2 -0
- basic_memory/schemas/response.py +84 -27
- basic_memory/schemas/search.py +5 -0
- basic_memory/schemas/sync_report.py +1 -1
- 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/context_service.py +219 -43
- basic_memory/services/directory_service.py +26 -11
- basic_memory/services/entity_service.py +68 -33
- basic_memory/services/file_service.py +131 -16
- basic_memory/services/initialization.py +51 -26
- basic_memory/services/link_resolver.py +1 -0
- basic_memory/services/project_service.py +68 -43
- basic_memory/services/search_service.py +75 -16
- basic_memory/sync/__init__.py +2 -1
- basic_memory/sync/coordinator.py +160 -0
- basic_memory/sync/sync_service.py +135 -115
- basic_memory/sync/watch_service.py +32 -12
- basic_memory/telemetry.py +249 -0
- basic_memory/utils.py +96 -75
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
- basic_memory-0.17.4.dist-info/RECORD +193 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
- basic_memory-0.16.1.dist-info/RECORD +0 -148
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
"""Router for knowledge graph operations.
|
|
1
|
+
"""Router for knowledge graph operations.
|
|
2
|
+
|
|
3
|
+
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
|
|
4
|
+
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
|
|
5
|
+
of path-based identifiers for improved performance and stability.
|
|
6
|
+
|
|
7
|
+
Migration guide: See docs/migration/v1-to-v2.md
|
|
8
|
+
"""
|
|
2
9
|
|
|
3
10
|
from typing import Annotated
|
|
4
11
|
|
|
@@ -25,7 +32,11 @@ from basic_memory.schemas import (
|
|
|
25
32
|
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
|
26
33
|
from basic_memory.schemas.base import Permalink, Entity
|
|
27
34
|
|
|
28
|
-
router = APIRouter(
|
|
35
|
+
router = APIRouter(
|
|
36
|
+
prefix="/knowledge",
|
|
37
|
+
tags=["knowledge"],
|
|
38
|
+
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
|
|
39
|
+
)
|
|
29
40
|
|
|
30
41
|
|
|
31
42
|
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
|
@@ -40,9 +51,10 @@ async def resolve_relations_background(sync_service, entity_id: int, entity_perm
|
|
|
40
51
|
logger.debug(
|
|
41
52
|
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
|
42
53
|
)
|
|
43
|
-
except Exception as e:
|
|
44
|
-
# Log but don't fail - this is a background task
|
|
45
|
-
|
|
54
|
+
except Exception as e: # pragma: no cover
|
|
55
|
+
# Log but don't fail - this is a background task.
|
|
56
|
+
# Avoid forcing synthetic failures just for coverage.
|
|
57
|
+
logger.warning( # pragma: no cover
|
|
46
58
|
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
|
47
59
|
)
|
|
48
60
|
|
|
@@ -50,6 +50,8 @@ async def get_project(
|
|
|
50
50
|
) # pragma: no cover
|
|
51
51
|
|
|
52
52
|
return ProjectItem(
|
|
53
|
+
id=found_project.id,
|
|
54
|
+
external_id=found_project.external_id,
|
|
53
55
|
name=found_project.name,
|
|
54
56
|
path=normalize_project_path(found_project.path),
|
|
55
57
|
is_default=found_project.is_default or False,
|
|
@@ -80,9 +82,18 @@ async def update_project(
|
|
|
80
82
|
raise HTTPException(status_code=400, detail="Path must be absolute")
|
|
81
83
|
|
|
82
84
|
# Get original project info for the response
|
|
85
|
+
old_project = await project_service.get_project(name)
|
|
86
|
+
if not old_project:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=400, detail=f"Project '{name}' not found in configuration"
|
|
89
|
+
)
|
|
90
|
+
|
|
83
91
|
old_project_info = ProjectItem(
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
id=old_project.id,
|
|
93
|
+
external_id=old_project.external_id,
|
|
94
|
+
name=old_project.name,
|
|
95
|
+
path=old_project.path,
|
|
96
|
+
is_default=old_project.is_default or False,
|
|
86
97
|
)
|
|
87
98
|
|
|
88
99
|
if path:
|
|
@@ -91,17 +102,27 @@ async def update_project(
|
|
|
91
102
|
await project_service.update_project(name, is_active=is_active)
|
|
92
103
|
|
|
93
104
|
# Get updated project info
|
|
94
|
-
|
|
105
|
+
updated_project = await project_service.get_project(name)
|
|
106
|
+
if not updated_project:
|
|
107
|
+
raise HTTPException( # pragma: no cover
|
|
108
|
+
status_code=404, detail=f"Project '{name}' not found after update"
|
|
109
|
+
)
|
|
95
110
|
|
|
96
111
|
return ProjectStatusResponse(
|
|
97
112
|
message=f"Project '{name}' updated successfully",
|
|
98
113
|
status="success",
|
|
99
114
|
default=(name == project_service.default_project),
|
|
100
115
|
old_project=old_project_info,
|
|
101
|
-
new_project=ProjectItem(
|
|
116
|
+
new_project=ProjectItem(
|
|
117
|
+
id=updated_project.id,
|
|
118
|
+
external_id=updated_project.external_id,
|
|
119
|
+
name=updated_project.name,
|
|
120
|
+
path=updated_project.path,
|
|
121
|
+
is_default=updated_project.is_default or False,
|
|
122
|
+
),
|
|
102
123
|
)
|
|
103
124
|
except ValueError as e:
|
|
104
|
-
raise HTTPException(status_code=400, detail=str(e))
|
|
125
|
+
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
|
105
126
|
|
|
106
127
|
|
|
107
128
|
# Sync project filesystem
|
|
@@ -165,10 +186,10 @@ async def project_sync_status(
|
|
|
165
186
|
Returns:
|
|
166
187
|
Scan report with details on files that need syncing
|
|
167
188
|
"""
|
|
168
|
-
logger.info(f"Scanning filesystem for project: {project_config.name}")
|
|
169
|
-
sync_report = await sync_service.scan(project_config.home)
|
|
189
|
+
logger.info(f"Scanning filesystem for project: {project_config.name}") # pragma: no cover
|
|
190
|
+
sync_report = await sync_service.scan(project_config.home) # pragma: no cover
|
|
170
191
|
|
|
171
|
-
return SyncReportResponse.from_sync_report(sync_report)
|
|
192
|
+
return SyncReportResponse.from_sync_report(sync_report) # pragma: no cover
|
|
172
193
|
|
|
173
194
|
|
|
174
195
|
# List all available projects
|
|
@@ -186,6 +207,8 @@ async def list_projects(
|
|
|
186
207
|
|
|
187
208
|
project_items = [
|
|
188
209
|
ProjectItem(
|
|
210
|
+
id=project.id,
|
|
211
|
+
external_id=project.external_id,
|
|
189
212
|
name=project.name,
|
|
190
213
|
path=normalize_project_path(project.path),
|
|
191
214
|
is_default=project.is_default or False,
|
|
@@ -232,6 +255,8 @@ async def add_project(
|
|
|
232
255
|
status="success",
|
|
233
256
|
default=existing_project.is_default or False,
|
|
234
257
|
new_project=ProjectItem(
|
|
258
|
+
id=existing_project.id,
|
|
259
|
+
external_id=existing_project.external_id,
|
|
235
260
|
name=existing_project.name,
|
|
236
261
|
path=existing_project.path,
|
|
237
262
|
is_default=existing_project.is_default or False,
|
|
@@ -250,12 +275,21 @@ async def add_project(
|
|
|
250
275
|
project_data.name, project_data.path, set_default=project_data.set_default
|
|
251
276
|
)
|
|
252
277
|
|
|
278
|
+
# Fetch the newly created project to get its ID
|
|
279
|
+
new_project = await project_service.get_project(project_data.name)
|
|
280
|
+
if not new_project:
|
|
281
|
+
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
|
282
|
+
|
|
253
283
|
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
|
254
|
-
message=f"Project '{
|
|
284
|
+
message=f"Project '{new_project.name}' added successfully",
|
|
255
285
|
status="success",
|
|
256
286
|
default=project_data.set_default,
|
|
257
287
|
new_project=ProjectItem(
|
|
258
|
-
|
|
288
|
+
id=new_project.id,
|
|
289
|
+
external_id=new_project.external_id,
|
|
290
|
+
name=new_project.name,
|
|
291
|
+
path=new_project.path,
|
|
292
|
+
is_default=new_project.is_default or False,
|
|
259
293
|
),
|
|
260
294
|
)
|
|
261
295
|
except ValueError as e: # pragma: no cover
|
|
@@ -303,10 +337,16 @@ async def remove_project(
|
|
|
303
337
|
await project_service.remove_project(name, delete_notes=delete_notes)
|
|
304
338
|
|
|
305
339
|
return ProjectStatusResponse(
|
|
306
|
-
message=f"Project '{name}' removed successfully",
|
|
340
|
+
message=f"Project '{old_project.name}' removed successfully",
|
|
307
341
|
status="success",
|
|
308
342
|
default=False,
|
|
309
|
-
old_project=ProjectItem(
|
|
343
|
+
old_project=ProjectItem(
|
|
344
|
+
id=old_project.id,
|
|
345
|
+
external_id=old_project.external_id,
|
|
346
|
+
name=old_project.name,
|
|
347
|
+
path=old_project.path,
|
|
348
|
+
is_default=old_project.is_default or False,
|
|
349
|
+
),
|
|
310
350
|
new_project=None,
|
|
311
351
|
)
|
|
312
352
|
except ValueError as e: # pragma: no cover
|
|
@@ -349,8 +389,16 @@ async def set_default_project(
|
|
|
349
389
|
message=f"Project '{name}' set as default successfully",
|
|
350
390
|
status="success",
|
|
351
391
|
default=True,
|
|
352
|
-
old_project=ProjectItem(
|
|
392
|
+
old_project=ProjectItem(
|
|
393
|
+
id=default_project.id,
|
|
394
|
+
external_id=default_project.external_id,
|
|
395
|
+
name=default_name,
|
|
396
|
+
path=default_project.path,
|
|
397
|
+
is_default=False,
|
|
398
|
+
),
|
|
353
399
|
new_project=ProjectItem(
|
|
400
|
+
id=new_default_project.id,
|
|
401
|
+
external_id=new_default_project.external_id,
|
|
354
402
|
name=name,
|
|
355
403
|
path=new_default_project.path,
|
|
356
404
|
is_default=True,
|
|
@@ -378,7 +426,13 @@ async def get_default_project(
|
|
|
378
426
|
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
|
379
427
|
)
|
|
380
428
|
|
|
381
|
-
return ProjectItem(
|
|
429
|
+
return ProjectItem(
|
|
430
|
+
id=default_project.id,
|
|
431
|
+
external_id=default_project.external_id,
|
|
432
|
+
name=default_project.name,
|
|
433
|
+
path=default_project.path,
|
|
434
|
+
is_default=True,
|
|
435
|
+
)
|
|
382
436
|
|
|
383
437
|
|
|
384
438
|
# Synchronize projects between config and database
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import tempfile
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Annotated
|
|
5
|
+
from typing import Annotated, Union
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body
|
|
7
|
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
|
|
8
8
|
from fastapi.responses import FileResponse, JSONResponse
|
|
9
9
|
from loguru import logger
|
|
10
10
|
|
|
@@ -25,6 +25,17 @@ from datetime import datetime
|
|
|
25
25
|
router = APIRouter(prefix="/resource", tags=["resources"])
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _mtime_to_datetime(entity: EntityModel) -> datetime:
|
|
29
|
+
"""Convert entity mtime (file modification time) to datetime.
|
|
30
|
+
|
|
31
|
+
Returns the file's actual modification time, falling back to updated_at
|
|
32
|
+
if mtime is not available.
|
|
33
|
+
"""
|
|
34
|
+
if entity.mtime: # pragma: no cover
|
|
35
|
+
return datetime.fromtimestamp(entity.mtime).astimezone() # pragma: no cover
|
|
36
|
+
return entity.updated_at
|
|
37
|
+
|
|
38
|
+
|
|
28
39
|
def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
|
29
40
|
match item.type:
|
|
30
41
|
case SearchItemType.ENTITY:
|
|
@@ -39,7 +50,7 @@ def get_entity_ids(item: SearchIndexRow) -> set[int]:
|
|
|
39
50
|
raise ValueError(f"Unexpected type: {item.type}")
|
|
40
51
|
|
|
41
52
|
|
|
42
|
-
@router.get("/{identifier:path}")
|
|
53
|
+
@router.get("/{identifier:path}", response_model=None)
|
|
43
54
|
async def get_resource_content(
|
|
44
55
|
config: ProjectConfigDep,
|
|
45
56
|
link_resolver: LinkResolverDep,
|
|
@@ -50,7 +61,7 @@ async def get_resource_content(
|
|
|
50
61
|
identifier: str,
|
|
51
62
|
page: int = 1,
|
|
52
63
|
page_size: int = 10,
|
|
53
|
-
) -> FileResponse:
|
|
64
|
+
) -> Union[Response, FileResponse]:
|
|
54
65
|
"""Get resource content by identifier: name or permalink."""
|
|
55
66
|
logger.debug(f"Getting content for: {identifier}")
|
|
56
67
|
|
|
@@ -81,13 +92,16 @@ async def get_resource_content(
|
|
|
81
92
|
# return single response
|
|
82
93
|
if len(results) == 1:
|
|
83
94
|
entity = results[0]
|
|
84
|
-
|
|
85
|
-
if not
|
|
95
|
+
# Check file exists via file_service (for cloud compatibility)
|
|
96
|
+
if not await file_service.exists(entity.file_path):
|
|
86
97
|
raise HTTPException(
|
|
87
98
|
status_code=404,
|
|
88
|
-
detail=f"File not found: {file_path}",
|
|
99
|
+
detail=f"File not found: {entity.file_path}",
|
|
89
100
|
)
|
|
90
|
-
|
|
101
|
+
# Read content via file_service as bytes (works with both local and S3)
|
|
102
|
+
content = await file_service.read_file_bytes(entity.file_path)
|
|
103
|
+
content_type = file_service.content_type(entity.file_path)
|
|
104
|
+
return Response(content=content, media_type=content_type)
|
|
91
105
|
|
|
92
106
|
# for multiple files, initialize a temporary file for writing the results
|
|
93
107
|
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
|
|
@@ -97,7 +111,7 @@ async def get_resource_content(
|
|
|
97
111
|
# Read content for each entity
|
|
98
112
|
content = await file_service.read_entity_content(result)
|
|
99
113
|
memory_url = normalize_memory_url(result.permalink)
|
|
100
|
-
modified_date = result.
|
|
114
|
+
modified_date = _mtime_to_datetime(result).isoformat()
|
|
101
115
|
checksum = result.checksum[:8] if result.checksum else ""
|
|
102
116
|
|
|
103
117
|
# Prepare the delimited content
|
|
@@ -155,11 +169,11 @@ async def write_resource(
|
|
|
155
169
|
# FastAPI should validate this, but if a dict somehow gets through
|
|
156
170
|
# (e.g., via JSON body parsing), we need to catch it here
|
|
157
171
|
if isinstance(content, dict):
|
|
158
|
-
logger.error(
|
|
172
|
+
logger.error( # pragma: no cover
|
|
159
173
|
f"Error writing resource {file_path}: "
|
|
160
174
|
f"content is a dict, expected string. Keys: {list(content.keys())}"
|
|
161
175
|
)
|
|
162
|
-
raise HTTPException(
|
|
176
|
+
raise HTTPException( # pragma: no cover
|
|
163
177
|
status_code=400,
|
|
164
178
|
detail="content must be a string, not a dict. "
|
|
165
179
|
"Ensure request body is sent as raw string content, not JSON object.",
|
|
@@ -171,21 +185,17 @@ async def write_resource(
|
|
|
171
185
|
else:
|
|
172
186
|
content_str = str(content)
|
|
173
187
|
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
-
|
|
180
|
-
# Write content to file
|
|
181
|
-
checksum = await file_service.write_file(full_path, content_str)
|
|
188
|
+
# Cloud compatibility: do not assume a local filesystem path structure.
|
|
189
|
+
# Delegate directory creation + writes to the configured FileService (local or S3).
|
|
190
|
+
await file_service.ensure_directory(Path(file_path).parent)
|
|
191
|
+
checksum = await file_service.write_file(file_path, content_str)
|
|
182
192
|
|
|
183
193
|
# Get file info
|
|
184
|
-
|
|
194
|
+
file_metadata = await file_service.get_file_metadata(file_path)
|
|
185
195
|
|
|
186
196
|
# Determine file details
|
|
187
197
|
file_name = Path(file_path).name
|
|
188
|
-
content_type = file_service.content_type(
|
|
198
|
+
content_type = file_service.content_type(file_path)
|
|
189
199
|
|
|
190
200
|
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
|
|
191
201
|
|
|
@@ -202,7 +212,7 @@ async def write_resource(
|
|
|
202
212
|
"content_type": content_type,
|
|
203
213
|
"file_path": file_path,
|
|
204
214
|
"checksum": checksum,
|
|
205
|
-
"updated_at":
|
|
215
|
+
"updated_at": file_metadata.modified_at,
|
|
206
216
|
},
|
|
207
217
|
)
|
|
208
218
|
status_code = 200
|
|
@@ -214,8 +224,8 @@ async def write_resource(
|
|
|
214
224
|
content_type=content_type,
|
|
215
225
|
file_path=file_path,
|
|
216
226
|
checksum=checksum,
|
|
217
|
-
created_at=
|
|
218
|
-
updated_at=
|
|
227
|
+
created_at=file_metadata.created_at,
|
|
228
|
+
updated_at=file_metadata.modified_at,
|
|
219
229
|
)
|
|
220
230
|
entity = await entity_repository.add(entity)
|
|
221
231
|
status_code = 201
|
|
@@ -229,9 +239,9 @@ async def write_resource(
|
|
|
229
239
|
content={
|
|
230
240
|
"file_path": file_path,
|
|
231
241
|
"checksum": checksum,
|
|
232
|
-
"size":
|
|
233
|
-
"created_at":
|
|
234
|
-
"modified_at":
|
|
242
|
+
"size": file_metadata.size,
|
|
243
|
+
"created_at": file_metadata.created_at.timestamp(),
|
|
244
|
+
"modified_at": file_metadata.modified_at.timestamp(),
|
|
235
245
|
},
|
|
236
246
|
)
|
|
237
247
|
except Exception as e: # pragma: no cover
|
|
@@ -24,11 +24,30 @@ async def to_graph_context(
|
|
|
24
24
|
page: Optional[int] = None,
|
|
25
25
|
page_size: Optional[int] = None,
|
|
26
26
|
):
|
|
27
|
+
# First pass: collect all entity IDs needed for relations
|
|
28
|
+
entity_ids_needed: set[int] = set()
|
|
29
|
+
for context_item in context_result.results:
|
|
30
|
+
for item in (
|
|
31
|
+
[context_item.primary_result] + context_item.observations + context_item.related_results
|
|
32
|
+
):
|
|
33
|
+
if item.type == SearchItemType.RELATION:
|
|
34
|
+
if item.from_id: # pyright: ignore
|
|
35
|
+
entity_ids_needed.add(item.from_id) # pyright: ignore
|
|
36
|
+
if item.to_id:
|
|
37
|
+
entity_ids_needed.add(item.to_id)
|
|
38
|
+
|
|
39
|
+
# Batch fetch all entities at once
|
|
40
|
+
entity_lookup: dict[int, str] = {}
|
|
41
|
+
if entity_ids_needed:
|
|
42
|
+
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
|
|
43
|
+
entity_lookup = {e.id: e.title for e in entities}
|
|
44
|
+
|
|
27
45
|
# Helper function to convert items to summaries
|
|
28
|
-
|
|
46
|
+
def to_summary(item: SearchIndexRow | ContextResultRow):
|
|
29
47
|
match item.type:
|
|
30
48
|
case SearchItemType.ENTITY:
|
|
31
49
|
return EntitySummary(
|
|
50
|
+
entity_id=item.id,
|
|
32
51
|
title=item.title, # pyright: ignore
|
|
33
52
|
permalink=item.permalink,
|
|
34
53
|
content=item.content,
|
|
@@ -37,6 +56,8 @@ async def to_graph_context(
|
|
|
37
56
|
)
|
|
38
57
|
case SearchItemType.OBSERVATION:
|
|
39
58
|
return ObservationSummary(
|
|
59
|
+
observation_id=item.id,
|
|
60
|
+
entity_id=item.entity_id, # pyright: ignore
|
|
40
61
|
title=item.title, # pyright: ignore
|
|
41
62
|
file_path=item.file_path,
|
|
42
63
|
category=item.category, # pyright: ignore
|
|
@@ -45,15 +66,19 @@ async def to_graph_context(
|
|
|
45
66
|
created_at=item.created_at,
|
|
46
67
|
)
|
|
47
68
|
case SearchItemType.RELATION:
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
|
70
|
+
to_title = entity_lookup.get(item.to_id) if item.to_id else None
|
|
50
71
|
return RelationSummary(
|
|
72
|
+
relation_id=item.id,
|
|
73
|
+
entity_id=item.entity_id, # pyright: ignore
|
|
51
74
|
title=item.title, # pyright: ignore
|
|
52
75
|
file_path=item.file_path,
|
|
53
76
|
permalink=item.permalink, # pyright: ignore
|
|
54
77
|
relation_type=item.relation_type, # pyright: ignore
|
|
55
|
-
from_entity=
|
|
56
|
-
|
|
78
|
+
from_entity=from_title,
|
|
79
|
+
from_entity_id=item.from_id, # pyright: ignore
|
|
80
|
+
to_entity=to_title,
|
|
81
|
+
to_entity_id=item.to_id,
|
|
57
82
|
created_at=item.created_at,
|
|
58
83
|
)
|
|
59
84
|
case _: # pragma: no cover
|
|
@@ -63,23 +88,19 @@ async def to_graph_context(
|
|
|
63
88
|
hierarchical_results = []
|
|
64
89
|
for context_item in context_result.results:
|
|
65
90
|
# Process primary result
|
|
66
|
-
primary_result =
|
|
91
|
+
primary_result = to_summary(context_item.primary_result)
|
|
67
92
|
|
|
68
|
-
# Process observations
|
|
69
|
-
observations = []
|
|
70
|
-
for obs in context_item.observations:
|
|
71
|
-
observations.append(await to_summary(obs))
|
|
93
|
+
# Process observations (always ObservationSummary, validated by context_service)
|
|
94
|
+
observations = [to_summary(obs) for obs in context_item.observations]
|
|
72
95
|
|
|
73
96
|
# Process related results
|
|
74
|
-
related = []
|
|
75
|
-
for rel in context_item.related_results:
|
|
76
|
-
related.append(await to_summary(rel))
|
|
97
|
+
related = [to_summary(rel) for rel in context_item.related_results]
|
|
77
98
|
|
|
78
99
|
# Add to hierarchical results
|
|
79
100
|
hierarchical_results.append(
|
|
80
101
|
ContextResult(
|
|
81
102
|
primary_result=primary_result,
|
|
82
|
-
observations=observations,
|
|
103
|
+
observations=observations, # pyright: ignore[reportArgumentType]
|
|
83
104
|
related_results=related,
|
|
84
105
|
)
|
|
85
106
|
)
|
|
@@ -111,6 +132,21 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
|
|
111
132
|
search_results = []
|
|
112
133
|
for r in results:
|
|
113
134
|
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
|
135
|
+
|
|
136
|
+
# Determine which IDs to set based on type
|
|
137
|
+
entity_id = None
|
|
138
|
+
observation_id = None
|
|
139
|
+
relation_id = None
|
|
140
|
+
|
|
141
|
+
if r.type == SearchItemType.ENTITY:
|
|
142
|
+
entity_id = r.id
|
|
143
|
+
elif r.type == SearchItemType.OBSERVATION:
|
|
144
|
+
observation_id = r.id
|
|
145
|
+
entity_id = r.entity_id # Parent entity
|
|
146
|
+
elif r.type == SearchItemType.RELATION:
|
|
147
|
+
relation_id = r.id
|
|
148
|
+
entity_id = r.entity_id # Parent entity
|
|
149
|
+
|
|
114
150
|
search_results.append(
|
|
115
151
|
SearchResult(
|
|
116
152
|
title=r.title, # pyright: ignore
|
|
@@ -121,6 +157,9 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
|
|
121
157
|
content=r.content,
|
|
122
158
|
file_path=r.file_path,
|
|
123
159
|
metadata=r.metadata,
|
|
160
|
+
entity_id=entity_id,
|
|
161
|
+
observation_id=observation_id,
|
|
162
|
+
relation_id=relation_id,
|
|
124
163
|
category=r.category,
|
|
125
164
|
from_entity=entities[0].permalink if entities else None,
|
|
126
165
|
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""API v2 module - ID-based entity references.
|
|
2
|
+
|
|
3
|
+
Version 2 of the Basic Memory API uses integer entity IDs as the primary
|
|
4
|
+
identifier for improved performance and stability.
|
|
5
|
+
|
|
6
|
+
Key changes from v1:
|
|
7
|
+
- Entity lookups use integer IDs instead of paths/permalinks
|
|
8
|
+
- Direct database queries instead of cascading resolution
|
|
9
|
+
- Stable references that don't change with file moves
|
|
10
|
+
- Better caching support
|
|
11
|
+
|
|
12
|
+
All v2 routers are registered with the /v2 prefix.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from basic_memory.api.v2.routers import (
|
|
16
|
+
knowledge_router,
|
|
17
|
+
memory_router,
|
|
18
|
+
project_router,
|
|
19
|
+
resource_router,
|
|
20
|
+
search_router,
|
|
21
|
+
directory_router,
|
|
22
|
+
prompt_router,
|
|
23
|
+
importer_router,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"knowledge_router",
|
|
28
|
+
"memory_router",
|
|
29
|
+
"project_router",
|
|
30
|
+
"resource_router",
|
|
31
|
+
"search_router",
|
|
32
|
+
"directory_router",
|
|
33
|
+
"prompt_router",
|
|
34
|
+
"importer_router",
|
|
35
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""V2 API routers."""
|
|
2
|
+
|
|
3
|
+
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
|
|
4
|
+
from basic_memory.api.v2.routers.project_router import router as project_router
|
|
5
|
+
from basic_memory.api.v2.routers.memory_router import router as memory_router
|
|
6
|
+
from basic_memory.api.v2.routers.search_router import router as search_router
|
|
7
|
+
from basic_memory.api.v2.routers.resource_router import router as resource_router
|
|
8
|
+
from basic_memory.api.v2.routers.directory_router import router as directory_router
|
|
9
|
+
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
|
|
10
|
+
from basic_memory.api.v2.routers.importer_router import router as importer_router
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"knowledge_router",
|
|
14
|
+
"project_router",
|
|
15
|
+
"memory_router",
|
|
16
|
+
"search_router",
|
|
17
|
+
"resource_router",
|
|
18
|
+
"directory_router",
|
|
19
|
+
"prompt_router",
|
|
20
|
+
"importer_router",
|
|
21
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""V2 Directory Router - ID-based directory tree operations.
|
|
2
|
+
|
|
3
|
+
This router provides directory structure browsing for projects using
|
|
4
|
+
external_id UUIDs instead of name-based identifiers.
|
|
5
|
+
|
|
6
|
+
Key improvements:
|
|
7
|
+
- Direct project lookup via external_id UUIDs
|
|
8
|
+
- Consistent with other v2 endpoints
|
|
9
|
+
- Better performance through indexed queries
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Query, Path
|
|
15
|
+
|
|
16
|
+
from basic_memory.deps import DirectoryServiceV2ExternalDep
|
|
17
|
+
from basic_memory.schemas.directory import DirectoryNode
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/directory", tags=["directory-v2"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
|
|
23
|
+
async def get_directory_tree(
|
|
24
|
+
directory_service: DirectoryServiceV2ExternalDep,
|
|
25
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
26
|
+
):
|
|
27
|
+
"""Get hierarchical directory structure from the knowledge base.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
directory_service: Service for directory operations
|
|
31
|
+
project_id: Project external UUID
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
DirectoryNode representing the root of the hierarchical tree structure
|
|
35
|
+
"""
|
|
36
|
+
# Get a hierarchical directory tree for the specific project
|
|
37
|
+
tree = await directory_service.get_directory_tree()
|
|
38
|
+
|
|
39
|
+
# Return the hierarchical tree
|
|
40
|
+
return tree
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
|
44
|
+
async def get_directory_structure(
|
|
45
|
+
directory_service: DirectoryServiceV2ExternalDep,
|
|
46
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
47
|
+
):
|
|
48
|
+
"""Get folder structure for navigation (no files).
|
|
49
|
+
|
|
50
|
+
Optimized endpoint for folder tree navigation. Returns only directory nodes
|
|
51
|
+
without file metadata. For full tree with files, use /directory/tree.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
directory_service: Service for directory operations
|
|
55
|
+
project_id: Project external UUID
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
DirectoryNode tree containing only folders (type="directory")
|
|
59
|
+
"""
|
|
60
|
+
structure = await directory_service.get_directory_structure()
|
|
61
|
+
return structure
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
|
65
|
+
async def list_directory(
|
|
66
|
+
directory_service: DirectoryServiceV2ExternalDep,
|
|
67
|
+
project_id: str = Path(..., description="Project external UUID"),
|
|
68
|
+
dir_name: str = Query("/", description="Directory path to list"),
|
|
69
|
+
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
|
70
|
+
file_name_glob: Optional[str] = Query(
|
|
71
|
+
None, description="Glob pattern for filtering file names"
|
|
72
|
+
),
|
|
73
|
+
):
|
|
74
|
+
"""List directory contents with filtering and depth control.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
directory_service: Service for directory operations
|
|
78
|
+
project_id: Project external UUID
|
|
79
|
+
dir_name: Directory path to list (default: root "/")
|
|
80
|
+
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
|
81
|
+
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of DirectoryNode objects matching the criteria
|
|
85
|
+
"""
|
|
86
|
+
# Get directory listing with filtering
|
|
87
|
+
nodes = await directory_service.list_directory(
|
|
88
|
+
dir_name=dir_name,
|
|
89
|
+
depth=depth,
|
|
90
|
+
file_name_glob=file_name_glob,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return nodes
|