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.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,11 @@
1
1
  """API routers."""
2
2
 
3
3
  from . import knowledge_router as knowledge
4
+ from . import management_router as management
4
5
  from . import memory_router as memory
6
+ from . import project_router as project
5
7
  from . import resource_router as resource
6
8
  from . import search_router as search
9
+ from . import prompt_router as prompt
7
10
 
8
- __all__ = ["knowledge", "memory", "resource", "search"]
11
+ __all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt"]
@@ -0,0 +1,84 @@
1
+ """Router for directory tree operations."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from fastapi import APIRouter, Query
6
+
7
+ from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
8
+ from basic_memory.schemas.directory import DirectoryNode
9
+
10
+ router = APIRouter(prefix="/directory", tags=["directory"])
11
+
12
+
13
+ @router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
14
+ async def get_directory_tree(
15
+ directory_service: DirectoryServiceDep,
16
+ project_id: ProjectIdDep,
17
+ ):
18
+ """Get hierarchical directory structure from the knowledge base.
19
+
20
+ Args:
21
+ directory_service: Service for directory operations
22
+ project_id: ID of the current project
23
+
24
+ Returns:
25
+ DirectoryNode representing the root of the hierarchical tree structure
26
+ """
27
+ # Get a hierarchical directory tree for the specific project
28
+ tree = await directory_service.get_directory_tree()
29
+
30
+ # Return the hierarchical tree
31
+ return tree
32
+
33
+
34
+ @router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
35
+ async def get_directory_structure(
36
+ directory_service: DirectoryServiceDep,
37
+ project_id: ProjectIdDep,
38
+ ):
39
+ """Get folder structure for navigation (no files).
40
+
41
+ Optimized endpoint for folder tree navigation. Returns only directory nodes
42
+ without file metadata. For full tree with files, use /directory/tree.
43
+
44
+ Args:
45
+ directory_service: Service for directory operations
46
+ project_id: ID of the current project
47
+
48
+ Returns:
49
+ DirectoryNode tree containing only folders (type="directory")
50
+ """
51
+ structure = await directory_service.get_directory_structure()
52
+ return structure
53
+
54
+
55
+ @router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
56
+ async def list_directory(
57
+ directory_service: DirectoryServiceDep,
58
+ project_id: ProjectIdDep,
59
+ dir_name: str = Query("/", description="Directory path to list"),
60
+ depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
61
+ file_name_glob: Optional[str] = Query(
62
+ None, description="Glob pattern for filtering file names"
63
+ ),
64
+ ):
65
+ """List directory contents with filtering and depth control.
66
+
67
+ Args:
68
+ directory_service: Service for directory operations
69
+ project_id: ID of the current project
70
+ dir_name: Directory path to list (default: root "/")
71
+ depth: Recursion depth (1-10, default: 1 for immediate children only)
72
+ file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
73
+
74
+ Returns:
75
+ List of DirectoryNode objects matching the criteria
76
+ """
77
+ # Get directory listing with filtering
78
+ nodes = await directory_service.list_directory(
79
+ dir_name=dir_name,
80
+ depth=depth,
81
+ file_name_glob=file_name_glob,
82
+ )
83
+
84
+ return nodes
@@ -0,0 +1,152 @@
1
+ """Import router for Basic Memory API."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ from fastapi import APIRouter, Form, HTTPException, UploadFile, status
7
+
8
+ from basic_memory.deps import (
9
+ ChatGPTImporterDep,
10
+ ClaudeConversationsImporterDep,
11
+ ClaudeProjectsImporterDep,
12
+ MemoryJsonImporterDep,
13
+ )
14
+ from basic_memory.importers import Importer
15
+ from basic_memory.schemas.importer import (
16
+ ChatImportResult,
17
+ EntityImportResult,
18
+ ProjectImportResult,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ router = APIRouter(prefix="/import", tags=["import"])
24
+
25
+
26
+ @router.post("/chatgpt", response_model=ChatImportResult)
27
+ async def import_chatgpt(
28
+ importer: ChatGPTImporterDep,
29
+ file: UploadFile,
30
+ folder: str = Form("conversations"),
31
+ ) -> ChatImportResult:
32
+ """Import conversations from ChatGPT JSON export.
33
+
34
+ Args:
35
+ file: The ChatGPT conversations.json file.
36
+ folder: The folder to place the files in.
37
+ markdown_processor: MarkdownProcessor instance.
38
+
39
+ Returns:
40
+ ChatImportResult with import statistics.
41
+
42
+ Raises:
43
+ HTTPException: If import fails.
44
+ """
45
+ return await import_file(importer, file, folder)
46
+
47
+
48
+ @router.post("/claude/conversations", response_model=ChatImportResult)
49
+ async def import_claude_conversations(
50
+ importer: ClaudeConversationsImporterDep,
51
+ file: UploadFile,
52
+ folder: str = Form("conversations"),
53
+ ) -> ChatImportResult:
54
+ """Import conversations from Claude conversations.json export.
55
+
56
+ Args:
57
+ file: The Claude conversations.json file.
58
+ folder: The folder to place the files in.
59
+ markdown_processor: MarkdownProcessor instance.
60
+
61
+ Returns:
62
+ ChatImportResult with import statistics.
63
+
64
+ Raises:
65
+ HTTPException: If import fails.
66
+ """
67
+ return await import_file(importer, file, folder)
68
+
69
+
70
+ @router.post("/claude/projects", response_model=ProjectImportResult)
71
+ async def import_claude_projects(
72
+ importer: ClaudeProjectsImporterDep,
73
+ file: UploadFile,
74
+ folder: str = Form("projects"),
75
+ ) -> ProjectImportResult:
76
+ """Import projects from Claude projects.json export.
77
+
78
+ Args:
79
+ file: The Claude projects.json file.
80
+ base_folder: The base folder to place the files in.
81
+ markdown_processor: MarkdownProcessor instance.
82
+
83
+ Returns:
84
+ ProjectImportResult with import statistics.
85
+
86
+ Raises:
87
+ HTTPException: If import fails.
88
+ """
89
+ return await import_file(importer, file, folder)
90
+
91
+
92
+ @router.post("/memory-json", response_model=EntityImportResult)
93
+ async def import_memory_json(
94
+ importer: MemoryJsonImporterDep,
95
+ file: UploadFile,
96
+ folder: str = Form("conversations"),
97
+ ) -> EntityImportResult:
98
+ """Import entities and relations from a memory.json file.
99
+
100
+ Args:
101
+ file: The memory.json file.
102
+ destination_folder: Optional destination folder within the project.
103
+ markdown_processor: MarkdownProcessor instance.
104
+
105
+ Returns:
106
+ EntityImportResult with import statistics.
107
+
108
+ Raises:
109
+ HTTPException: If import fails.
110
+ """
111
+ try:
112
+ file_data = []
113
+ file_bytes = await file.read()
114
+ file_str = file_bytes.decode("utf-8")
115
+ for line in file_str.splitlines():
116
+ json_data = json.loads(line)
117
+ file_data.append(json_data)
118
+
119
+ result = await importer.import_data(file_data, folder)
120
+ if not result.success: # pragma: no cover
121
+ raise HTTPException(
122
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
123
+ detail=result.error_message or "Import failed",
124
+ )
125
+ except Exception as e:
126
+ logger.exception("Import failed")
127
+ raise HTTPException(
128
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
129
+ detail=f"Import failed: {str(e)}",
130
+ )
131
+ return result
132
+
133
+
134
+ async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
135
+ try:
136
+ # Process file
137
+ json_data = json.load(file.file)
138
+ result = await importer.import_data(json_data, destination_folder)
139
+ if not result.success: # pragma: no cover
140
+ raise HTTPException(
141
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
142
+ detail=result.error_message or "Import failed",
143
+ )
144
+
145
+ return result
146
+
147
+ except Exception as e:
148
+ logger.exception("Import failed")
149
+ raise HTTPException(
150
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
151
+ detail=f"Import failed: {str(e)}",
152
+ )
@@ -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
 
@@ -10,6 +17,11 @@ from basic_memory.deps import (
10
17
  get_search_service,
11
18
  SearchServiceDep,
12
19
  LinkResolverDep,
20
+ ProjectPathDep,
21
+ FileServiceDep,
22
+ ProjectConfigDep,
23
+ AppConfigDep,
24
+ SyncServiceDep,
13
25
  )
14
26
  from basic_memory.schemas import (
15
27
  EntityListResponse,
@@ -17,10 +29,35 @@ from basic_memory.schemas import (
17
29
  DeleteEntitiesResponse,
18
30
  DeleteEntitiesRequest,
19
31
  )
32
+ from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
20
33
  from basic_memory.schemas.base import Permalink, Entity
21
- from basic_memory.services.exceptions import EntityNotFoundError
22
34
 
23
- 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
+ )
40
+
41
+
42
+ async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
43
+ """Background task to resolve relations for a specific entity.
44
+
45
+ This runs asynchronously after the API response is sent, preventing
46
+ long delays when creating entities with many relations.
47
+ """
48
+ try:
49
+ # Only resolve relations for the newly created entity
50
+ await sync_service.resolve_relations(entity_id=entity_id)
51
+ logger.debug(
52
+ f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
53
+ )
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
58
+ f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
59
+ )
60
+
24
61
 
25
62
  ## Create endpoints
26
63
 
@@ -33,7 +70,9 @@ async def create_entity(
33
70
  search_service: SearchServiceDep,
34
71
  ) -> EntityResponse:
35
72
  """Create an entity."""
36
- logger.info(f"request: create_entity with data={data}")
73
+ logger.info(
74
+ "API request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
75
+ )
37
76
 
38
77
  entity = await entity_service.create_entity(data)
39
78
 
@@ -41,25 +80,38 @@ async def create_entity(
41
80
  await search_service.index_entity(entity, background_tasks=background_tasks)
42
81
  result = EntityResponse.model_validate(entity)
43
82
 
44
- logger.info(f"response: create_entity with result={result}")
83
+ logger.info(
84
+ f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
85
+ )
45
86
  return result
46
87
 
47
88
 
48
89
  @router.put("/entities/{permalink:path}", response_model=EntityResponse)
49
90
  async def create_or_update_entity(
91
+ project: ProjectPathDep,
50
92
  permalink: Permalink,
51
93
  data: Entity,
52
94
  response: Response,
53
95
  background_tasks: BackgroundTasks,
54
96
  entity_service: EntityServiceDep,
55
97
  search_service: SearchServiceDep,
98
+ file_service: FileServiceDep,
99
+ sync_service: SyncServiceDep,
56
100
  ) -> EntityResponse:
57
101
  """Create or update an entity. If entity exists, it will be updated, otherwise created."""
58
- logger.info(f"request: create_or_update_entity with permalink={permalink}, data={data}")
102
+ logger.info(
103
+ f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
104
+ )
59
105
 
60
106
  # Validate permalink matches
61
107
  if data.permalink != permalink:
62
- raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
108
+ logger.warning(
109
+ f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
110
+ )
111
+ raise HTTPException(
112
+ status_code=400,
113
+ detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
114
+ )
63
115
 
64
116
  # Try create_or_update operation
65
117
  entity, created = await entity_service.create_or_update_entity(data)
@@ -67,36 +119,141 @@ async def create_or_update_entity(
67
119
 
68
120
  # reindex
69
121
  await search_service.index_entity(entity, background_tasks=background_tasks)
122
+
123
+ # Schedule relation resolution as a background task for new entities
124
+ # This prevents blocking the API response while resolving potentially many relations
125
+ if created:
126
+ background_tasks.add_task(
127
+ resolve_relations_background, sync_service, entity.id, entity.permalink or ""
128
+ )
129
+
70
130
  result = EntityResponse.model_validate(entity)
71
131
 
72
132
  logger.info(
73
- f"response: create_or_update_entity with result={result}, status_code={response.status_code}"
133
+ f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
74
134
  )
75
135
  return result
76
136
 
77
137
 
138
+ @router.patch("/entities/{identifier:path}", response_model=EntityResponse)
139
+ async def edit_entity(
140
+ identifier: str,
141
+ data: EditEntityRequest,
142
+ background_tasks: BackgroundTasks,
143
+ entity_service: EntityServiceDep,
144
+ search_service: SearchServiceDep,
145
+ ) -> EntityResponse:
146
+ """Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
147
+
148
+ This endpoint allows for targeted edits without requiring the full entity content.
149
+ """
150
+ logger.info(
151
+ f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
152
+ )
153
+
154
+ try:
155
+ # Edit the entity using the service
156
+ entity = await entity_service.edit_entity(
157
+ identifier=identifier,
158
+ operation=data.operation,
159
+ content=data.content,
160
+ section=data.section,
161
+ find_text=data.find_text,
162
+ expected_replacements=data.expected_replacements,
163
+ )
164
+
165
+ # Reindex the updated entity
166
+ await search_service.index_entity(entity, background_tasks=background_tasks)
167
+
168
+ # Return the updated entity response
169
+ result = EntityResponse.model_validate(entity)
170
+
171
+ logger.info(
172
+ "API response",
173
+ endpoint="edit_entity",
174
+ identifier=identifier,
175
+ operation=data.operation,
176
+ permalink=result.permalink,
177
+ status_code=200,
178
+ )
179
+
180
+ return result
181
+
182
+ except Exception as e:
183
+ logger.error(f"Error editing entity: {e}")
184
+ raise HTTPException(status_code=400, detail=str(e))
185
+
186
+
187
+ @router.post("/move")
188
+ async def move_entity(
189
+ data: MoveEntityRequest,
190
+ background_tasks: BackgroundTasks,
191
+ entity_service: EntityServiceDep,
192
+ project_config: ProjectConfigDep,
193
+ app_config: AppConfigDep,
194
+ search_service: SearchServiceDep,
195
+ ) -> EntityResponse:
196
+ """Move an entity to a new file location with project consistency.
197
+
198
+ This endpoint moves a note to a different path while maintaining project
199
+ consistency and optionally updating permalinks based on configuration.
200
+ """
201
+ logger.info(
202
+ f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
203
+ )
204
+
205
+ try:
206
+ # Move the entity using the service
207
+ moved_entity = await entity_service.move_entity(
208
+ identifier=data.identifier,
209
+ destination_path=data.destination_path,
210
+ project_config=project_config,
211
+ app_config=app_config,
212
+ )
213
+
214
+ # Get the moved entity to reindex it
215
+ entity = await entity_service.link_resolver.resolve_link(data.destination_path)
216
+ if entity:
217
+ await search_service.index_entity(entity, background_tasks=background_tasks)
218
+
219
+ logger.info(
220
+ "API response",
221
+ endpoint="move_entity",
222
+ identifier=data.identifier,
223
+ destination=data.destination_path,
224
+ status_code=200,
225
+ )
226
+ result = EntityResponse.model_validate(moved_entity)
227
+ return result
228
+
229
+ except Exception as e:
230
+ logger.error(f"Error moving entity: {e}")
231
+ raise HTTPException(status_code=400, detail=str(e))
232
+
233
+
78
234
  ## Read endpoints
79
235
 
80
236
 
81
- @router.get("/entities/{permalink:path}", response_model=EntityResponse)
237
+ @router.get("/entities/{identifier:path}", response_model=EntityResponse)
82
238
  async def get_entity(
83
239
  entity_service: EntityServiceDep,
84
- permalink: str,
240
+ link_resolver: LinkResolverDep,
241
+ identifier: str,
85
242
  ) -> EntityResponse:
86
- """Get a specific entity by ID.
243
+ """Get a specific entity by file path or permalink..
87
244
 
88
245
  Args:
89
- permalink: Entity path ID
90
- content: If True, include full file content
246
+ identifier: Entity file path or permalink
91
247
  :param entity_service: EntityService
248
+ :param link_resolver: LinkResolver
92
249
  """
93
- logger.info(f"request: get_entity with permalink={permalink}")
94
- try:
95
- entity = await entity_service.get_by_permalink(permalink)
96
- result = EntityResponse.model_validate(entity)
97
- return result
98
- except EntityNotFoundError:
99
- raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
250
+ logger.info(f"request: get_entity with identifier={identifier}")
251
+ entity = await link_resolver.resolve_link(identifier)
252
+ if not entity:
253
+ raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
254
+
255
+ result = EntityResponse.model_validate(entity)
256
+ return result
100
257
 
101
258
 
102
259
  @router.get("/entities", response_model=EntityListResponse)
@@ -133,10 +290,10 @@ async def delete_entity(
133
290
  return DeleteEntitiesResponse(deleted=False)
134
291
 
135
292
  # Delete the entity
136
- deleted = await entity_service.delete_entity(entity.permalink)
293
+ deleted = await entity_service.delete_entity(entity.permalink or entity.id)
137
294
 
138
- # Remove from search index
139
- background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
295
+ # Remove from search index (entity, observations, and relations)
296
+ background_tasks.add_task(search_service.handle_delete, entity)
140
297
 
141
298
  result = DeleteEntitiesResponse(deleted=deleted)
142
299
  return result
@@ -0,0 +1,80 @@
1
+ """Management router for basic-memory API."""
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, Request
6
+ from loguru import logger
7
+ from pydantic import BaseModel
8
+
9
+ from basic_memory.config import ConfigManager
10
+ from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
11
+
12
+ router = APIRouter(prefix="/management", tags=["management"])
13
+
14
+
15
+ class WatchStatusResponse(BaseModel):
16
+ """Response model for watch status."""
17
+
18
+ running: bool
19
+ """Whether the watch service is currently running."""
20
+
21
+
22
+ @router.get("/watch/status", response_model=WatchStatusResponse)
23
+ async def get_watch_status(request: Request) -> WatchStatusResponse:
24
+ """Get the current status of the watch service."""
25
+ return WatchStatusResponse(
26
+ running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
27
+ )
28
+
29
+
30
+ @router.post("/watch/start", response_model=WatchStatusResponse)
31
+ async def start_watch_service(
32
+ request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
33
+ ) -> WatchStatusResponse:
34
+ """Start the watch service if it's not already running."""
35
+
36
+ # needed because of circular imports from sync -> app
37
+ from basic_memory.sync import WatchService
38
+ from basic_memory.sync.background_sync import create_background_sync_task
39
+
40
+ if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
41
+ # Watch service is already running
42
+ return WatchStatusResponse(running=True)
43
+
44
+ app_config = ConfigManager().config
45
+
46
+ # Create and start a new watch service
47
+ logger.info("Starting watch service via management API")
48
+
49
+ # Get services needed for the watch task
50
+ watch_service = WatchService(
51
+ app_config=app_config,
52
+ project_repository=project_repository,
53
+ )
54
+
55
+ # Create and store the task
56
+ watch_task = create_background_sync_task(sync_service, watch_service)
57
+ request.app.state.watch_task = watch_task
58
+
59
+ return WatchStatusResponse(running=True)
60
+
61
+
62
+ @router.post("/watch/stop", response_model=WatchStatusResponse)
63
+ async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
64
+ """Stop the watch service if it's running."""
65
+ if request.app.state.watch_task is None or request.app.state.watch_task.done():
66
+ # Watch service is not running
67
+ return WatchStatusResponse(running=False)
68
+
69
+ # Cancel the running task
70
+ logger.info("Stopping watch service via management API")
71
+ request.app.state.watch_task.cancel()
72
+
73
+ # Wait for it to be properly cancelled
74
+ try:
75
+ await request.app.state.watch_task
76
+ except asyncio.CancelledError:
77
+ pass
78
+
79
+ request.app.state.watch_task = None
80
+ return WatchStatusResponse(running=False)