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.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {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(prefix="/knowledge", tags=["knowledge"])
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
- logger.warning(
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
- name=name,
85
- path=project_service.projects.get(name, ""),
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
- updated_path = path if path else project_service.projects.get(name, "")
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(name=name, path=updated_path),
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 '{project_data.name}' added successfully",
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
- name=project_data.name, path=project_data.path, is_default=project_data.set_default
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(name=old_project.name, path=old_project.path),
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(name=default_name, path=default_project.path),
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(name=default_project.name, path=default_project.path, is_default=True)
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
- file_path = Path(f"{config.home}/{entity.file_path}")
85
- if not file_path.exists():
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
- return FileResponse(path=file_path)
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.updated_at.isoformat()
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
- # Get full file path
175
- full_path = Path(f"{config.home}/{file_path}")
176
-
177
- # Ensure parent directory exists
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
- file_stats = file_service.file_stats(full_path)
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(full_path)
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": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
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=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
218
- updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
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": file_stats.st_size,
233
- "created_at": file_stats.st_ctime,
234
- "modified_at": file_stats.st_mtime,
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
- async def to_summary(item: SearchIndexRow | ContextResultRow):
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
- from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
49
- to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
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=from_entity.title if from_entity else None,
56
- to_entity=to_entity.title if to_entity else None,
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 = await to_summary(context_item.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