basic-memory 0.17.1__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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,270 @@
1
+ """V2 Prompt Router - ID-based prompt generation operations.
2
+
3
+ This router uses v2 dependencies for consistent project ID handling.
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
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
+ ContextServiceV2Dep,
17
+ EntityRepositoryV2Dep,
18
+ SearchServiceV2Dep,
19
+ EntityServiceV2Dep,
20
+ ProjectIdPathDep,
21
+ )
22
+ from basic_memory.schemas.prompt import (
23
+ ContinueConversationRequest,
24
+ SearchPromptRequest,
25
+ PromptResponse,
26
+ PromptMetadata,
27
+ )
28
+ from basic_memory.schemas.search import SearchItemType, SearchQuery
29
+
30
+ router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
31
+
32
+
33
+ @router.post("/continue-conversation", response_model=PromptResponse)
34
+ async def continue_conversation(
35
+ project_id: ProjectIdPathDep,
36
+ search_service: SearchServiceV2Dep,
37
+ entity_service: EntityServiceV2Dep,
38
+ context_service: ContextServiceV2Dep,
39
+ entity_repository: EntityRepositoryV2Dep,
40
+ request: ContinueConversationRequest,
41
+ ) -> PromptResponse:
42
+ """Generate a prompt for continuing a conversation.
43
+
44
+ This endpoint takes a topic and/or timeframe and generates a prompt with
45
+ relevant context from the knowledge base.
46
+
47
+ Args:
48
+ project_id: Validated numeric project ID from URL path
49
+ request: The request parameters
50
+
51
+ Returns:
52
+ Formatted continuation prompt with context
53
+ """
54
+ logger.info(
55
+ f"V2 Generating continue conversation prompt for project {project_id}, "
56
+ f"topic: {request.topic}, timeframe: {request.timeframe}"
57
+ )
58
+
59
+ since = parse_timeframe(request.timeframe) if request.timeframe else None
60
+
61
+ # Initialize search results
62
+ search_results = []
63
+
64
+ # Get data needed for template
65
+ if request.topic:
66
+ query = SearchQuery(text=request.topic, after_date=request.timeframe)
67
+ results = await search_service.search(query, limit=request.search_items_limit)
68
+ search_results = await to_search_results(entity_service, results)
69
+
70
+ # Build context from results
71
+ all_hierarchical_results = []
72
+ for result in search_results:
73
+ if hasattr(result, "permalink") and result.permalink:
74
+ # Get hierarchical context using the new dataclass-based approach
75
+ context_result = await context_service.build_context(
76
+ result.permalink,
77
+ depth=request.depth,
78
+ since=since,
79
+ max_related=request.related_items_limit,
80
+ include_observations=True, # Include observations for entities
81
+ )
82
+
83
+ # Process results into the schema format
84
+ graph_context = await to_graph_context(
85
+ context_result, entity_repository=entity_repository
86
+ )
87
+
88
+ # Add results to our collection (limit to top results for each permalink)
89
+ if graph_context.results:
90
+ all_hierarchical_results.extend(graph_context.results[:3])
91
+
92
+ # Limit to a reasonable number of total results
93
+ all_hierarchical_results = all_hierarchical_results[:10]
94
+
95
+ template_context = {
96
+ "topic": request.topic,
97
+ "timeframe": request.timeframe,
98
+ "hierarchical_results": all_hierarchical_results,
99
+ "has_results": len(all_hierarchical_results) > 0,
100
+ }
101
+ else:
102
+ # If no topic, get recent activity
103
+ context_result = await context_service.build_context(
104
+ types=[SearchItemType.ENTITY],
105
+ depth=request.depth,
106
+ since=since,
107
+ max_related=request.related_items_limit,
108
+ include_observations=True,
109
+ )
110
+ recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
111
+
112
+ hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
113
+
114
+ template_context = {
115
+ "topic": f"Recent Activity from ({request.timeframe})",
116
+ "timeframe": request.timeframe,
117
+ "hierarchical_results": hierarchical_results,
118
+ "has_results": len(hierarchical_results) > 0,
119
+ }
120
+
121
+ try:
122
+ # Render template
123
+ rendered_prompt = await template_loader.render(
124
+ "prompts/continue_conversation.hbs", template_context
125
+ )
126
+
127
+ # Calculate metadata
128
+ # Count items of different types
129
+ observation_count = 0
130
+ relation_count = 0
131
+ entity_count = 0
132
+
133
+ # Get the hierarchical results from the template context
134
+ hierarchical_results_for_count = template_context.get("hierarchical_results", [])
135
+
136
+ # For topic-based search
137
+ if request.topic:
138
+ for item in hierarchical_results_for_count:
139
+ if hasattr(item, "observations"):
140
+ observation_count += len(item.observations) if item.observations else 0
141
+
142
+ if hasattr(item, "related_results"):
143
+ for related in item.related_results or []:
144
+ if hasattr(related, "type"):
145
+ if related.type == "relation":
146
+ relation_count += 1
147
+ elif related.type == "entity": # pragma: no cover
148
+ entity_count += 1 # pragma: no cover
149
+ # For recent activity
150
+ else:
151
+ for item in hierarchical_results_for_count:
152
+ if hasattr(item, "observations"):
153
+ observation_count += len(item.observations) if item.observations else 0
154
+
155
+ if hasattr(item, "related_results"):
156
+ for related in item.related_results or []:
157
+ if hasattr(related, "type"):
158
+ if related.type == "relation":
159
+ relation_count += 1
160
+ elif related.type == "entity": # pragma: no cover
161
+ entity_count += 1 # pragma: no cover
162
+
163
+ # Build metadata
164
+ metadata = {
165
+ "query": request.topic,
166
+ "timeframe": request.timeframe,
167
+ "search_count": len(search_results)
168
+ if request.topic
169
+ else 0, # Original search results count
170
+ "context_count": len(hierarchical_results_for_count),
171
+ "observation_count": observation_count,
172
+ "relation_count": relation_count,
173
+ "total_items": (
174
+ len(hierarchical_results_for_count)
175
+ + observation_count
176
+ + relation_count
177
+ + entity_count
178
+ ),
179
+ "search_limit": request.search_items_limit,
180
+ "context_depth": request.depth,
181
+ "related_limit": request.related_items_limit,
182
+ "generated_at": datetime.now(timezone.utc).isoformat(),
183
+ }
184
+
185
+ prompt_metadata = PromptMetadata(**metadata)
186
+
187
+ return PromptResponse(
188
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
189
+ )
190
+ except Exception as e:
191
+ logger.error(f"Error rendering continue conversation template: {e}")
192
+ raise HTTPException(
193
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
194
+ detail=f"Error rendering prompt template: {str(e)}",
195
+ )
196
+
197
+
198
+ @router.post("/search", response_model=PromptResponse)
199
+ async def search_prompt(
200
+ project_id: ProjectIdPathDep,
201
+ search_service: SearchServiceV2Dep,
202
+ entity_service: EntityServiceV2Dep,
203
+ request: SearchPromptRequest,
204
+ page: int = 1,
205
+ page_size: int = 10,
206
+ ) -> PromptResponse:
207
+ """Generate a prompt for search results.
208
+
209
+ This endpoint takes a search query and formats the results into a helpful
210
+ prompt with context and suggestions.
211
+
212
+ Args:
213
+ project_id: Validated numeric project ID from URL path
214
+ request: The search parameters
215
+ page: The page number for pagination
216
+ page_size: The number of results per page, defaults to 10
217
+
218
+ Returns:
219
+ Formatted search results prompt with context
220
+ """
221
+ logger.info(
222
+ f"V2 Generating search prompt for project {project_id}, "
223
+ f"query: {request.query}, timeframe: {request.timeframe}"
224
+ )
225
+
226
+ limit = page_size
227
+ offset = (page - 1) * page_size
228
+
229
+ query = SearchQuery(text=request.query, after_date=request.timeframe)
230
+ results = await search_service.search(query, limit=limit, offset=offset)
231
+ search_results = await to_search_results(entity_service, results)
232
+
233
+ template_context = {
234
+ "query": request.query,
235
+ "timeframe": request.timeframe,
236
+ "results": search_results,
237
+ "has_results": len(search_results) > 0,
238
+ "result_count": len(search_results),
239
+ }
240
+
241
+ try:
242
+ # Render template
243
+ rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
244
+
245
+ # Build metadata
246
+ metadata = {
247
+ "query": request.query,
248
+ "timeframe": request.timeframe,
249
+ "search_count": len(search_results),
250
+ "context_count": len(search_results),
251
+ "observation_count": 0, # Search results don't include observations
252
+ "relation_count": 0, # Search results don't include relations
253
+ "total_items": len(search_results),
254
+ "search_limit": limit,
255
+ "context_depth": 0, # No context depth for basic search
256
+ "related_limit": 0, # No related items for basic search
257
+ "generated_at": datetime.now(timezone.utc).isoformat(),
258
+ }
259
+
260
+ prompt_metadata = PromptMetadata(**metadata)
261
+
262
+ return PromptResponse(
263
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
264
+ )
265
+ except Exception as e:
266
+ logger.error(f"Error rendering search template: {e}")
267
+ raise HTTPException(
268
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
269
+ detail=f"Error rendering prompt template: {str(e)}",
270
+ )
@@ -0,0 +1,286 @@
1
+ """V2 Resource Router - ID-based resource content operations.
2
+
3
+ This router uses entity IDs for all operations, with file paths in request bodies
4
+ when needed. This is consistent with v2's ID-first design.
5
+
6
+ Key differences from v1:
7
+ - Uses integer entity IDs in URL paths instead of 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
13
+
14
+ from fastapi import APIRouter, HTTPException, Response
15
+ from loguru import logger
16
+
17
+ from basic_memory.deps import (
18
+ ProjectConfigV2Dep,
19
+ EntityServiceV2Dep,
20
+ FileServiceV2Dep,
21
+ EntityRepositoryV2Dep,
22
+ SearchServiceV2Dep,
23
+ ProjectIdPathDep,
24
+ )
25
+ from basic_memory.models.knowledge import Entity as EntityModel
26
+ from basic_memory.schemas.v2.resource import (
27
+ CreateResourceRequest,
28
+ UpdateResourceRequest,
29
+ ResourceResponse,
30
+ )
31
+ from basic_memory.utils import validate_project_path
32
+
33
+ router = APIRouter(prefix="/resource", tags=["resources-v2"])
34
+
35
+
36
+ @router.get("/{entity_id}")
37
+ async def get_resource_content(
38
+ project_id: ProjectIdPathDep,
39
+ entity_id: int,
40
+ config: ProjectConfigV2Dep,
41
+ entity_service: EntityServiceV2Dep,
42
+ file_service: FileServiceV2Dep,
43
+ ) -> Response:
44
+ """Get raw resource content by entity ID.
45
+
46
+ Args:
47
+ project_id: Validated numeric project ID from URL path
48
+ entity_id: Numeric entity ID
49
+ config: Project configuration
50
+ entity_service: Entity service for fetching entity data
51
+ file_service: File service for reading file content
52
+
53
+ Returns:
54
+ Response with entity content
55
+
56
+ Raises:
57
+ HTTPException: 404 if entity or file not found
58
+ """
59
+ logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
60
+
61
+ # Get entity by ID
62
+ entities = await entity_service.get_entities_by_id([entity_id])
63
+ if not entities:
64
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
65
+
66
+ entity = entities[0]
67
+
68
+ # Validate entity file path to prevent path traversal
69
+ project_path = Path(config.home)
70
+ if not validate_project_path(entity.file_path, project_path):
71
+ logger.error(f"Invalid file path in entity {entity.id}: {entity.file_path}")
72
+ raise HTTPException(
73
+ status_code=500,
74
+ detail="Entity contains invalid file path",
75
+ )
76
+
77
+ # Check file exists via file_service (for cloud compatibility)
78
+ if not await file_service.exists(entity.file_path):
79
+ raise HTTPException(
80
+ status_code=404,
81
+ detail=f"File not found: {entity.file_path}",
82
+ )
83
+
84
+ # Read content via file_service as bytes (works with both local and S3)
85
+ content = await file_service.read_file_bytes(entity.file_path)
86
+ content_type = file_service.content_type(entity.file_path)
87
+
88
+ return Response(content=content, media_type=content_type)
89
+
90
+
91
+ @router.post("", response_model=ResourceResponse)
92
+ async def create_resource(
93
+ project_id: ProjectIdPathDep,
94
+ data: CreateResourceRequest,
95
+ config: ProjectConfigV2Dep,
96
+ file_service: FileServiceV2Dep,
97
+ entity_repository: EntityRepositoryV2Dep,
98
+ search_service: SearchServiceV2Dep,
99
+ ) -> ResourceResponse:
100
+ """Create a new resource file.
101
+
102
+ Args:
103
+ project_id: Validated numeric project ID from URL path
104
+ data: Create resource request with file_path and content
105
+ config: Project configuration
106
+ file_service: File service for writing files
107
+ entity_repository: Entity repository for creating entities
108
+ search_service: Search service for indexing
109
+
110
+ Returns:
111
+ ResourceResponse with file information including entity_id
112
+
113
+ Raises:
114
+ HTTPException: 400 for invalid file paths, 409 if file already exists
115
+ """
116
+ try:
117
+ # Validate path to prevent path traversal attacks
118
+ project_path = Path(config.home)
119
+ if not validate_project_path(data.file_path, project_path):
120
+ logger.warning(
121
+ f"Invalid file path attempted: {data.file_path} in project {config.name}"
122
+ )
123
+ raise HTTPException(
124
+ status_code=400,
125
+ detail=f"Invalid file path: {data.file_path}. "
126
+ "Path must be relative and stay within project boundaries.",
127
+ )
128
+
129
+ # Check if entity already exists
130
+ existing_entity = await entity_repository.get_by_file_path(data.file_path)
131
+ if existing_entity:
132
+ raise HTTPException(
133
+ status_code=409,
134
+ detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
135
+ f"Use PUT /resource/{existing_entity.id} to update it.",
136
+ )
137
+
138
+ # Cloud compatibility: avoid assuming a local filesystem path.
139
+ # Delegate directory creation + writes to FileService (local or S3).
140
+ await file_service.ensure_directory(Path(data.file_path).parent)
141
+ checksum = await file_service.write_file(data.file_path, data.content)
142
+
143
+ # Get file info
144
+ file_metadata = await file_service.get_file_metadata(data.file_path)
145
+
146
+ # Determine file details
147
+ file_name = Path(data.file_path).name
148
+ content_type = file_service.content_type(data.file_path)
149
+ entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
150
+
151
+ # Create a new entity model
152
+ entity = EntityModel(
153
+ title=file_name,
154
+ entity_type=entity_type,
155
+ content_type=content_type,
156
+ file_path=data.file_path,
157
+ checksum=checksum,
158
+ created_at=file_metadata.created_at,
159
+ updated_at=file_metadata.modified_at,
160
+ )
161
+ entity = await entity_repository.add(entity)
162
+
163
+ # Index the file for search
164
+ await search_service.index_entity(entity) # pyright: ignore
165
+
166
+ # Return success response
167
+ return ResourceResponse(
168
+ entity_id=entity.id,
169
+ file_path=data.file_path,
170
+ checksum=checksum,
171
+ size=file_metadata.size,
172
+ created_at=file_metadata.created_at.timestamp(),
173
+ modified_at=file_metadata.modified_at.timestamp(),
174
+ )
175
+ except HTTPException:
176
+ # Re-raise HTTP exceptions without wrapping
177
+ raise
178
+ except Exception as e: # pragma: no cover
179
+ logger.error(f"Error creating resource {data.file_path}: {e}")
180
+ raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
181
+
182
+
183
+ @router.put("/{entity_id}", response_model=ResourceResponse)
184
+ async def update_resource(
185
+ project_id: ProjectIdPathDep,
186
+ entity_id: int,
187
+ data: UpdateResourceRequest,
188
+ config: ProjectConfigV2Dep,
189
+ file_service: FileServiceV2Dep,
190
+ entity_repository: EntityRepositoryV2Dep,
191
+ search_service: SearchServiceV2Dep,
192
+ ) -> ResourceResponse:
193
+ """Update an existing resource by entity ID.
194
+
195
+ Can update content and optionally move the file to a new path.
196
+
197
+ Args:
198
+ project_id: Validated numeric project ID from URL path
199
+ entity_id: Entity ID of the resource to update
200
+ data: Update resource request with content and optional new file_path
201
+ config: Project configuration
202
+ file_service: File service for writing files
203
+ entity_repository: Entity repository for updating entities
204
+ search_service: Search service for indexing
205
+
206
+ Returns:
207
+ ResourceResponse with updated file information
208
+
209
+ Raises:
210
+ HTTPException: 404 if entity not found, 400 for invalid paths
211
+ """
212
+ try:
213
+ # Get existing entity
214
+ entity = await entity_repository.get_by_id(entity_id)
215
+ if not entity:
216
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
217
+
218
+ # Determine target file path
219
+ target_file_path = data.file_path if data.file_path else entity.file_path
220
+
221
+ # Validate path to prevent path traversal attacks
222
+ project_path = Path(config.home)
223
+ if not validate_project_path(target_file_path, project_path):
224
+ logger.warning(
225
+ f"Invalid file path attempted: {target_file_path} in project {config.name}"
226
+ )
227
+ raise HTTPException(
228
+ status_code=400,
229
+ detail=f"Invalid file path: {target_file_path}. "
230
+ "Path must be relative and stay within project boundaries.",
231
+ )
232
+
233
+ # If moving file, handle the move
234
+ if data.file_path and data.file_path != entity.file_path:
235
+ # Ensure new parent directory exists (no-op for S3)
236
+ await file_service.ensure_directory(Path(target_file_path).parent)
237
+
238
+ # If old file exists, remove it via file_service (for cloud compatibility)
239
+ if await file_service.exists(entity.file_path):
240
+ await file_service.delete_file(entity.file_path)
241
+ else:
242
+ # Ensure directory exists for in-place update
243
+ await file_service.ensure_directory(Path(target_file_path).parent)
244
+
245
+ # Write content to target file
246
+ checksum = await file_service.write_file(target_file_path, data.content)
247
+
248
+ # Get file info
249
+ file_metadata = await file_service.get_file_metadata(target_file_path)
250
+
251
+ # Determine file details
252
+ file_name = Path(target_file_path).name
253
+ content_type = file_service.content_type(target_file_path)
254
+ entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
255
+
256
+ # Update entity
257
+ updated_entity = await entity_repository.update(
258
+ entity_id,
259
+ {
260
+ "title": file_name,
261
+ "entity_type": entity_type,
262
+ "content_type": content_type,
263
+ "file_path": target_file_path,
264
+ "checksum": checksum,
265
+ "updated_at": file_metadata.modified_at,
266
+ },
267
+ )
268
+
269
+ # Index the updated file for search
270
+ await search_service.index_entity(updated_entity) # pyright: ignore
271
+
272
+ # Return success response
273
+ return ResourceResponse(
274
+ entity_id=entity_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 integer project IDs for stable, efficient routing.
4
+ V1 uses string-based project names which are less efficient and less stable.
5
+ """
6
+
7
+ from fastapi import APIRouter, BackgroundTasks
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 SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
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
+ project_id: ProjectIdPathDep,
20
+ query: SearchQuery,
21
+ search_service: SearchServiceV2Dep,
22
+ entity_service: EntityServiceV2Dep,
23
+ page: int = 1,
24
+ page_size: int = 10,
25
+ ):
26
+ """Search across all knowledge and documents in a project.
27
+
28
+ V2 uses integer project IDs for improved performance and stability.
29
+
30
+ Args:
31
+ project_id: Validated numeric project ID 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
+ project_id: ProjectIdPathDep,
55
+ background_tasks: BackgroundTasks,
56
+ search_service: SearchServiceV2Dep,
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: Validated numeric project ID 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"}
@@ -0,0 +1 @@
1
+ """CLI tools for basic-memory"""