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
@@ -0,0 +1,269 @@
1
+ """V2 Prompt Router - ID-based prompt generation operations.
2
+
3
+ This router uses v2 dependencies for consistent project handling with external_id UUIDs.
4
+ Prompt endpoints are action-based (not resource-based), so they don't
5
+ have entity IDs in URLs - they generate formatted prompts from queries.
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+ from fastapi import APIRouter, HTTPException, status, Path
10
+ from loguru import logger
11
+
12
+ from basic_memory.api.routers.utils import to_graph_context, to_search_results
13
+ from basic_memory.api.template_loader import template_loader
14
+ from basic_memory.schemas.base import parse_timeframe
15
+ from basic_memory.deps import (
16
+ ContextServiceV2ExternalDep,
17
+ EntityRepositoryV2ExternalDep,
18
+ SearchServiceV2ExternalDep,
19
+ EntityServiceV2ExternalDep,
20
+ )
21
+ from basic_memory.schemas.prompt import (
22
+ ContinueConversationRequest,
23
+ SearchPromptRequest,
24
+ PromptResponse,
25
+ PromptMetadata,
26
+ )
27
+ from basic_memory.schemas.search import SearchItemType, SearchQuery
28
+
29
+ router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
30
+
31
+
32
+ @router.post("/continue-conversation", response_model=PromptResponse)
33
+ async def continue_conversation(
34
+ search_service: SearchServiceV2ExternalDep,
35
+ entity_service: EntityServiceV2ExternalDep,
36
+ context_service: ContextServiceV2ExternalDep,
37
+ entity_repository: EntityRepositoryV2ExternalDep,
38
+ request: ContinueConversationRequest,
39
+ project_id: str = Path(..., description="Project external UUID"),
40
+ ) -> PromptResponse:
41
+ """Generate a prompt for continuing a conversation.
42
+
43
+ This endpoint takes a topic and/or timeframe and generates a prompt with
44
+ relevant context from the knowledge base.
45
+
46
+ Args:
47
+ project_id: Project external UUID from URL path
48
+ request: The request parameters
49
+
50
+ Returns:
51
+ Formatted continuation prompt with context
52
+ """
53
+ logger.info(
54
+ f"V2 Generating continue conversation prompt for project {project_id}, "
55
+ f"topic: {request.topic}, timeframe: {request.timeframe}"
56
+ )
57
+
58
+ since = parse_timeframe(request.timeframe) if request.timeframe else None
59
+
60
+ # Initialize search results
61
+ search_results = []
62
+
63
+ # Get data needed for template
64
+ if request.topic:
65
+ query = SearchQuery(text=request.topic, after_date=request.timeframe)
66
+ results = await search_service.search(query, limit=request.search_items_limit)
67
+ search_results = await to_search_results(entity_service, results)
68
+
69
+ # Build context from results
70
+ all_hierarchical_results = []
71
+ for result in search_results:
72
+ if hasattr(result, "permalink") and result.permalink:
73
+ # Get hierarchical context using the new dataclass-based approach
74
+ context_result = await context_service.build_context(
75
+ result.permalink,
76
+ depth=request.depth,
77
+ since=since,
78
+ max_related=request.related_items_limit,
79
+ include_observations=True, # Include observations for entities
80
+ )
81
+
82
+ # Process results into the schema format
83
+ graph_context = await to_graph_context(
84
+ context_result, entity_repository=entity_repository
85
+ )
86
+
87
+ # Add results to our collection (limit to top results for each permalink)
88
+ if graph_context.results:
89
+ all_hierarchical_results.extend(graph_context.results[:3])
90
+
91
+ # Limit to a reasonable number of total results
92
+ all_hierarchical_results = all_hierarchical_results[:10]
93
+
94
+ template_context = {
95
+ "topic": request.topic,
96
+ "timeframe": request.timeframe,
97
+ "hierarchical_results": all_hierarchical_results,
98
+ "has_results": len(all_hierarchical_results) > 0,
99
+ }
100
+ else:
101
+ # If no topic, get recent activity
102
+ context_result = await context_service.build_context(
103
+ types=[SearchItemType.ENTITY],
104
+ depth=request.depth,
105
+ since=since,
106
+ max_related=request.related_items_limit,
107
+ include_observations=True,
108
+ )
109
+ recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
110
+
111
+ hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
112
+
113
+ template_context = {
114
+ "topic": f"Recent Activity from ({request.timeframe})",
115
+ "timeframe": request.timeframe,
116
+ "hierarchical_results": hierarchical_results,
117
+ "has_results": len(hierarchical_results) > 0,
118
+ }
119
+
120
+ try:
121
+ # Render template
122
+ rendered_prompt = await template_loader.render(
123
+ "prompts/continue_conversation.hbs", template_context
124
+ )
125
+
126
+ # Calculate metadata
127
+ # Count items of different types
128
+ observation_count = 0
129
+ relation_count = 0
130
+ entity_count = 0
131
+
132
+ # Get the hierarchical results from the template context
133
+ hierarchical_results_for_count = template_context.get("hierarchical_results", [])
134
+
135
+ # For topic-based search
136
+ if request.topic:
137
+ for item in hierarchical_results_for_count:
138
+ if hasattr(item, "observations"):
139
+ observation_count += len(item.observations) if item.observations else 0
140
+
141
+ if hasattr(item, "related_results"):
142
+ for related in item.related_results or []:
143
+ if hasattr(related, "type"):
144
+ if related.type == "relation":
145
+ relation_count += 1
146
+ elif related.type == "entity": # pragma: no cover
147
+ entity_count += 1 # pragma: no cover
148
+ # For recent activity
149
+ else:
150
+ for item in hierarchical_results_for_count:
151
+ if hasattr(item, "observations"):
152
+ observation_count += len(item.observations) if item.observations else 0
153
+
154
+ if hasattr(item, "related_results"):
155
+ for related in item.related_results or []:
156
+ if hasattr(related, "type"):
157
+ if related.type == "relation":
158
+ relation_count += 1
159
+ elif related.type == "entity": # pragma: no cover
160
+ entity_count += 1 # pragma: no cover
161
+
162
+ # Build metadata
163
+ metadata = {
164
+ "query": request.topic,
165
+ "timeframe": request.timeframe,
166
+ "search_count": len(search_results)
167
+ if request.topic
168
+ else 0, # Original search results count
169
+ "context_count": len(hierarchical_results_for_count),
170
+ "observation_count": observation_count,
171
+ "relation_count": relation_count,
172
+ "total_items": (
173
+ len(hierarchical_results_for_count)
174
+ + observation_count
175
+ + relation_count
176
+ + entity_count
177
+ ),
178
+ "search_limit": request.search_items_limit,
179
+ "context_depth": request.depth,
180
+ "related_limit": request.related_items_limit,
181
+ "generated_at": datetime.now(timezone.utc).isoformat(),
182
+ }
183
+
184
+ prompt_metadata = PromptMetadata(**metadata)
185
+
186
+ return PromptResponse(
187
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
188
+ )
189
+ except Exception as e:
190
+ logger.error(f"Error rendering continue conversation template: {e}")
191
+ raise HTTPException(
192
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
193
+ detail=f"Error rendering prompt template: {str(e)}",
194
+ )
195
+
196
+
197
+ @router.post("/search", response_model=PromptResponse)
198
+ async def search_prompt(
199
+ search_service: SearchServiceV2ExternalDep,
200
+ entity_service: EntityServiceV2ExternalDep,
201
+ request: SearchPromptRequest,
202
+ project_id: str = Path(..., description="Project external UUID"),
203
+ page: int = 1,
204
+ page_size: int = 10,
205
+ ) -> PromptResponse:
206
+ """Generate a prompt for search results.
207
+
208
+ This endpoint takes a search query and formats the results into a helpful
209
+ prompt with context and suggestions.
210
+
211
+ Args:
212
+ project_id: Project external UUID from URL path
213
+ request: The search parameters
214
+ page: The page number for pagination
215
+ page_size: The number of results per page, defaults to 10
216
+
217
+ Returns:
218
+ Formatted search results prompt with context
219
+ """
220
+ logger.info(
221
+ f"V2 Generating search prompt for project {project_id}, "
222
+ f"query: {request.query}, timeframe: {request.timeframe}"
223
+ )
224
+
225
+ limit = page_size
226
+ offset = (page - 1) * page_size
227
+
228
+ query = SearchQuery(text=request.query, after_date=request.timeframe)
229
+ results = await search_service.search(query, limit=limit, offset=offset)
230
+ search_results = await to_search_results(entity_service, results)
231
+
232
+ template_context = {
233
+ "query": request.query,
234
+ "timeframe": request.timeframe,
235
+ "results": search_results,
236
+ "has_results": len(search_results) > 0,
237
+ "result_count": len(search_results),
238
+ }
239
+
240
+ try:
241
+ # Render template
242
+ rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
243
+
244
+ # Build metadata
245
+ metadata = {
246
+ "query": request.query,
247
+ "timeframe": request.timeframe,
248
+ "search_count": len(search_results),
249
+ "context_count": len(search_results),
250
+ "observation_count": 0, # Search results don't include observations
251
+ "relation_count": 0, # Search results don't include relations
252
+ "total_items": len(search_results),
253
+ "search_limit": limit,
254
+ "context_depth": 0, # No context depth for basic search
255
+ "related_limit": 0, # No related items for basic search
256
+ "generated_at": datetime.now(timezone.utc).isoformat(),
257
+ }
258
+
259
+ prompt_metadata = PromptMetadata(**metadata)
260
+
261
+ return PromptResponse(
262
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
263
+ )
264
+ except Exception as e:
265
+ logger.error(f"Error rendering search template: {e}")
266
+ raise HTTPException(
267
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
268
+ detail=f"Error rendering prompt template: {str(e)}",
269
+ )
@@ -0,0 +1,286 @@
1
+ """V2 Resource Router - ID-based resource content operations.
2
+
3
+ This router uses entity external_ids (UUIDs) for all operations, with file paths
4
+ in request bodies when needed. This is consistent with v2's external_id-first design.
5
+
6
+ Key differences from v1:
7
+ - Uses UUID external_ids in URL paths instead of integer IDs or file paths
8
+ - File paths are in request bodies for create/update operations
9
+ - More RESTful: POST for create, PUT for update, GET for read
10
+ """
11
+
12
+ from pathlib import Path as PathLib
13
+
14
+ from fastapi import APIRouter, HTTPException, Response, Path
15
+ from loguru import logger
16
+
17
+ from basic_memory.deps import (
18
+ ProjectConfigV2ExternalDep,
19
+ FileServiceV2ExternalDep,
20
+ EntityRepositoryV2ExternalDep,
21
+ SearchServiceV2ExternalDep,
22
+ )
23
+ from basic_memory.models.knowledge import Entity as EntityModel
24
+ from basic_memory.schemas.v2.resource import (
25
+ CreateResourceRequest,
26
+ UpdateResourceRequest,
27
+ ResourceResponse,
28
+ )
29
+ from basic_memory.utils import validate_project_path
30
+
31
+ router = APIRouter(prefix="/resource", tags=["resources-v2"])
32
+
33
+
34
+ @router.get("/{entity_id}")
35
+ async def get_resource_content(
36
+ config: ProjectConfigV2ExternalDep,
37
+ entity_repository: EntityRepositoryV2ExternalDep,
38
+ file_service: FileServiceV2ExternalDep,
39
+ project_id: str = Path(..., description="Project external UUID"),
40
+ entity_id: str = Path(..., description="Entity external UUID"),
41
+ ) -> Response:
42
+ """Get raw resource content by entity external_id.
43
+
44
+ Args:
45
+ project_id: Project external UUID from URL path
46
+ entity_id: Entity external UUID
47
+ config: Project configuration
48
+ entity_repository: Entity repository for fetching entity data
49
+ file_service: File service for reading file content
50
+
51
+ Returns:
52
+ Response with entity content
53
+
54
+ Raises:
55
+ HTTPException: 404 if entity or file not found
56
+ """
57
+ logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
58
+
59
+ # Get entity by external_id
60
+ entity = await entity_repository.get_by_external_id(entity_id)
61
+ if not entity:
62
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
63
+
64
+ # Validate entity file path to prevent path traversal
65
+ project_path = PathLib(config.home)
66
+ if not validate_project_path(entity.file_path, project_path):
67
+ logger.error( # pragma: no cover
68
+ f"Invalid file path in entity {entity.id}: {entity.file_path}"
69
+ )
70
+ raise HTTPException( # pragma: no cover
71
+ status_code=500,
72
+ detail="Entity contains invalid file path",
73
+ )
74
+
75
+ # Check file exists via file_service (for cloud compatibility)
76
+ if not await file_service.exists(entity.file_path):
77
+ raise HTTPException( # pragma: no cover
78
+ status_code=404,
79
+ detail=f"File not found: {entity.file_path}",
80
+ )
81
+
82
+ # Read content via file_service as bytes (works with both local and S3)
83
+ content = await file_service.read_file_bytes(entity.file_path)
84
+ content_type = file_service.content_type(entity.file_path)
85
+
86
+ return Response(content=content, media_type=content_type)
87
+
88
+
89
+ @router.post("", response_model=ResourceResponse)
90
+ async def create_resource(
91
+ data: CreateResourceRequest,
92
+ config: ProjectConfigV2ExternalDep,
93
+ file_service: FileServiceV2ExternalDep,
94
+ entity_repository: EntityRepositoryV2ExternalDep,
95
+ search_service: SearchServiceV2ExternalDep,
96
+ project_id: str = Path(..., description="Project external UUID"),
97
+ ) -> ResourceResponse:
98
+ """Create a new resource file.
99
+
100
+ Args:
101
+ project_id: Project external UUID from URL path
102
+ data: Create resource request with file_path and content
103
+ config: Project configuration
104
+ file_service: File service for writing files
105
+ entity_repository: Entity repository for creating entities
106
+ search_service: Search service for indexing
107
+
108
+ Returns:
109
+ ResourceResponse with file information including entity_id and external_id
110
+
111
+ Raises:
112
+ HTTPException: 400 for invalid file paths, 409 if file already exists
113
+ """
114
+ try:
115
+ # Validate path to prevent path traversal attacks
116
+ project_path = PathLib(config.home)
117
+ if not validate_project_path(data.file_path, project_path):
118
+ logger.warning(
119
+ f"Invalid file path attempted: {data.file_path} in project {config.name}"
120
+ )
121
+ raise HTTPException(
122
+ status_code=400,
123
+ detail=f"Invalid file path: {data.file_path}. "
124
+ "Path must be relative and stay within project boundaries.",
125
+ )
126
+
127
+ # Check if entity already exists
128
+ existing_entity = await entity_repository.get_by_file_path(data.file_path)
129
+ if existing_entity:
130
+ raise HTTPException(
131
+ status_code=409,
132
+ detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.external_id}. "
133
+ f"Use PUT /resource/{existing_entity.external_id} to update it.",
134
+ )
135
+
136
+ # Cloud compatibility: avoid assuming a local filesystem path.
137
+ # Delegate directory creation + writes to FileService (local or S3).
138
+ await file_service.ensure_directory(PathLib(data.file_path).parent)
139
+ checksum = await file_service.write_file(data.file_path, data.content)
140
+
141
+ # Get file info
142
+ file_metadata = await file_service.get_file_metadata(data.file_path)
143
+
144
+ # Determine file details
145
+ file_name = PathLib(data.file_path).name
146
+ content_type = file_service.content_type(data.file_path)
147
+ entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
148
+
149
+ # Create a new entity model
150
+ entity = EntityModel(
151
+ title=file_name,
152
+ entity_type=entity_type,
153
+ content_type=content_type,
154
+ file_path=data.file_path,
155
+ checksum=checksum,
156
+ created_at=file_metadata.created_at,
157
+ updated_at=file_metadata.modified_at,
158
+ )
159
+ entity = await entity_repository.add(entity)
160
+
161
+ # Index the file for search
162
+ await search_service.index_entity(entity) # pyright: ignore
163
+
164
+ # Return success response
165
+ return ResourceResponse(
166
+ entity_id=entity.id,
167
+ external_id=entity.external_id,
168
+ file_path=data.file_path,
169
+ checksum=checksum,
170
+ size=file_metadata.size,
171
+ created_at=file_metadata.created_at.timestamp(),
172
+ modified_at=file_metadata.modified_at.timestamp(),
173
+ )
174
+ except HTTPException:
175
+ # Re-raise HTTP exceptions without wrapping
176
+ raise
177
+ except Exception as e: # pragma: no cover
178
+ logger.error(f"Error creating resource {data.file_path}: {e}")
179
+ raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
180
+
181
+
182
+ @router.put("/{entity_id}", response_model=ResourceResponse)
183
+ async def update_resource(
184
+ data: UpdateResourceRequest,
185
+ config: ProjectConfigV2ExternalDep,
186
+ file_service: FileServiceV2ExternalDep,
187
+ entity_repository: EntityRepositoryV2ExternalDep,
188
+ search_service: SearchServiceV2ExternalDep,
189
+ project_id: str = Path(..., description="Project external UUID"),
190
+ entity_id: str = Path(..., description="Entity external UUID"),
191
+ ) -> ResourceResponse:
192
+ """Update an existing resource by entity external_id.
193
+
194
+ Can update content and optionally move the file to a new path.
195
+
196
+ Args:
197
+ project_id: Project external UUID from URL path
198
+ entity_id: Entity external UUID of the resource to update
199
+ data: Update resource request with content and optional new file_path
200
+ config: Project configuration
201
+ file_service: File service for writing files
202
+ entity_repository: Entity repository for updating entities
203
+ search_service: Search service for indexing
204
+
205
+ Returns:
206
+ ResourceResponse with updated file information
207
+
208
+ Raises:
209
+ HTTPException: 404 if entity not found, 400 for invalid paths
210
+ """
211
+ try:
212
+ # Get existing entity by external_id
213
+ entity = await entity_repository.get_by_external_id(entity_id)
214
+ if not entity:
215
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
216
+
217
+ # Determine target file path
218
+ target_file_path = data.file_path if data.file_path else entity.file_path
219
+
220
+ # Validate path to prevent path traversal attacks
221
+ project_path = PathLib(config.home)
222
+ if not validate_project_path(target_file_path, project_path):
223
+ logger.warning(
224
+ f"Invalid file path attempted: {target_file_path} in project {config.name}"
225
+ )
226
+ raise HTTPException(
227
+ status_code=400,
228
+ detail=f"Invalid file path: {target_file_path}. "
229
+ "Path must be relative and stay within project boundaries.",
230
+ )
231
+
232
+ # If moving file, handle the move
233
+ if data.file_path and data.file_path != entity.file_path:
234
+ # Ensure new parent directory exists (no-op for S3)
235
+ await file_service.ensure_directory(PathLib(target_file_path).parent)
236
+
237
+ # If old file exists, remove it via file_service (for cloud compatibility)
238
+ if await file_service.exists(entity.file_path):
239
+ await file_service.delete_file(entity.file_path)
240
+ else:
241
+ # Ensure directory exists for in-place update
242
+ await file_service.ensure_directory(PathLib(target_file_path).parent)
243
+
244
+ # Write content to target file
245
+ checksum = await file_service.write_file(target_file_path, data.content)
246
+
247
+ # Get file info
248
+ file_metadata = await file_service.get_file_metadata(target_file_path)
249
+
250
+ # Determine file details
251
+ file_name = PathLib(target_file_path).name
252
+ content_type = file_service.content_type(target_file_path)
253
+ entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
254
+
255
+ # Update entity using internal ID
256
+ updated_entity = await entity_repository.update(
257
+ entity.id,
258
+ {
259
+ "title": file_name,
260
+ "entity_type": entity_type,
261
+ "content_type": content_type,
262
+ "file_path": target_file_path,
263
+ "checksum": checksum,
264
+ "updated_at": file_metadata.modified_at,
265
+ },
266
+ )
267
+
268
+ # Index the updated file for search
269
+ await search_service.index_entity(updated_entity) # pyright: ignore
270
+
271
+ # Return success response
272
+ return ResourceResponse(
273
+ entity_id=entity.id,
274
+ external_id=entity.external_id,
275
+ file_path=target_file_path,
276
+ checksum=checksum,
277
+ size=file_metadata.size,
278
+ created_at=file_metadata.created_at.timestamp(),
279
+ modified_at=file_metadata.modified_at.timestamp(),
280
+ )
281
+ except HTTPException:
282
+ # Re-raise HTTP exceptions without wrapping
283
+ raise
284
+ except Exception as e: # pragma: no cover
285
+ logger.error(f"Error updating resource {entity_id}: {e}")
286
+ raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
@@ -0,0 +1,73 @@
1
+ """V2 router for search operations.
2
+
3
+ This router uses external_id UUIDs for stable, API-friendly routing.
4
+ V1 uses string-based project names which are less efficient and less stable.
5
+ """
6
+
7
+ from fastapi import APIRouter, BackgroundTasks, Path
8
+
9
+ from basic_memory.api.routers.utils import to_search_results
10
+ from basic_memory.schemas.search import SearchQuery, SearchResponse
11
+ from basic_memory.deps import SearchServiceV2ExternalDep, EntityServiceV2ExternalDep
12
+
13
+ # Note: No prefix here - it's added during registration as /v2/{project_id}/search
14
+ router = APIRouter(tags=["search"])
15
+
16
+
17
+ @router.post("/search/", response_model=SearchResponse)
18
+ async def search(
19
+ query: SearchQuery,
20
+ search_service: SearchServiceV2ExternalDep,
21
+ entity_service: EntityServiceV2ExternalDep,
22
+ project_id: str = Path(..., description="Project external UUID"),
23
+ page: int = 1,
24
+ page_size: int = 10,
25
+ ):
26
+ """Search across all knowledge and documents in a project.
27
+
28
+ V2 uses external_id UUIDs for stable API references.
29
+
30
+ Args:
31
+ project_id: Project external UUID from URL path
32
+ query: Search query parameters (text, filters, etc.)
33
+ search_service: Search service scoped to project
34
+ entity_service: Entity service scoped to project
35
+ page: Page number for pagination
36
+ page_size: Number of results per page
37
+
38
+ Returns:
39
+ SearchResponse with paginated search results
40
+ """
41
+ limit = page_size
42
+ offset = (page - 1) * page_size
43
+ results = await search_service.search(query, limit=limit, offset=offset)
44
+ search_results = await to_search_results(entity_service, results)
45
+ return SearchResponse(
46
+ results=search_results,
47
+ current_page=page,
48
+ page_size=page_size,
49
+ )
50
+
51
+
52
+ @router.post("/search/reindex")
53
+ async def reindex(
54
+ background_tasks: BackgroundTasks,
55
+ search_service: SearchServiceV2ExternalDep,
56
+ project_id: str = Path(..., description="Project external UUID"),
57
+ ):
58
+ """Recreate and populate the search index for a project.
59
+
60
+ This is a background operation that rebuilds the search index
61
+ from scratch. Useful after bulk updates or if the index becomes
62
+ corrupted.
63
+
64
+ Args:
65
+ project_id: Project external UUID from URL path
66
+ background_tasks: FastAPI background tasks handler
67
+ search_service: Search service scoped to project
68
+
69
+ Returns:
70
+ Status message indicating reindex has been initiated
71
+ """
72
+ await search_service.reindex_all(background_tasks=background_tasks)
73
+ return {"status": "ok", "message": "Reindex initiated"}
basic_memory/cli/app.py CHANGED
@@ -1,8 +1,22 @@
1
- from typing import Optional
1
+ # Suppress Logfire "not configured" warning - we only use Logfire in cloud/server contexts
2
+ import os
2
3
 
3
- import typer
4
+ os.environ.setdefault("LOGFIRE_IGNORE_NO_CONFIG", "1")
4
5
 
5
- from basic_memory.config import ConfigManager
6
+ # Remove loguru's default handler IMMEDIATELY, before any other imports.
7
+ # This prevents DEBUG logs from appearing on stdout during module-level
8
+ # initialization (e.g., template_loader.TemplateLoader() logs at DEBUG level).
9
+ from loguru import logger
10
+
11
+ logger.remove()
12
+
13
+ from typing import Optional # noqa: E402
14
+
15
+ import typer # noqa: E402
16
+
17
+ from basic_memory.cli.container import CliContainer, set_container # noqa: E402
18
+ from basic_memory.config import init_cli_logging # noqa: E402
19
+ from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
6
20
 
7
21
 
8
22
  def version_callback(value: bool) -> None:
@@ -31,12 +45,34 @@ def app_callback(
31
45
  ) -> None:
32
46
  """Basic Memory - Local-first personal knowledge management."""
33
47
 
34
- # Run initialization for every command unless --version was specified
35
- if not version and ctx.invoked_subcommand is not None:
48
+ # Initialize logging for CLI (file only, no stdout)
49
+ init_cli_logging()
50
+
51
+ # --- Composition Root ---
52
+ # Create container and read config (single point of config access)
53
+ container = CliContainer.create()
54
+ set_container(container)
55
+
56
+ # Show telemetry notice and track CLI startup
57
+ # Skip for 'mcp' command - it handles its own telemetry in lifespan
58
+ # Skip for 'telemetry' command - avoid issues when user is managing telemetry
59
+ if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
60
+ show_notice_if_needed()
61
+ track_app_started("cli")
62
+
63
+ # Run initialization for commands that don't use the API
64
+ # Skip for 'mcp' command - it has its own lifespan that handles initialization
65
+ # Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
66
+ # Skip for 'reset' command - it manages its own database lifecycle
67
+ skip_init_commands = {"mcp", "status", "sync", "project", "tool", "reset"}
68
+ if (
69
+ not version
70
+ and ctx.invoked_subcommand is not None
71
+ and ctx.invoked_subcommand not in skip_init_commands
72
+ ):
36
73
  from basic_memory.services.initialization import ensure_initialization
37
74
 
38
- app_config = ConfigManager().config
39
- ensure_initialization(app_config)
75
+ ensure_initialization(container.config)
40
76
 
41
77
 
42
78
  ## import