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,260 @@
1
+ """Router for prompt-related operations.
2
+
3
+ This router is responsible for rendering various prompts using Handlebars templates.
4
+ It centralizes all prompt formatting logic that was previously in the MCP prompts.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from fastapi import APIRouter, HTTPException, status
9
+ from loguru import logger
10
+
11
+ from basic_memory.api.routers.utils import to_graph_context, to_search_results
12
+ from basic_memory.api.template_loader import template_loader
13
+ from basic_memory.schemas.base import parse_timeframe
14
+ from basic_memory.deps import (
15
+ ContextServiceDep,
16
+ EntityRepositoryDep,
17
+ SearchServiceDep,
18
+ EntityServiceDep,
19
+ )
20
+ from basic_memory.schemas.prompt import (
21
+ ContinueConversationRequest,
22
+ SearchPromptRequest,
23
+ PromptResponse,
24
+ PromptMetadata,
25
+ )
26
+ from basic_memory.schemas.search import SearchItemType, SearchQuery
27
+
28
+ router = APIRouter(prefix="/prompt", tags=["prompt"])
29
+
30
+
31
+ @router.post("/continue-conversation", response_model=PromptResponse)
32
+ async def continue_conversation(
33
+ search_service: SearchServiceDep,
34
+ entity_service: EntityServiceDep,
35
+ context_service: ContextServiceDep,
36
+ entity_repository: EntityRepositoryDep,
37
+ request: ContinueConversationRequest,
38
+ ) -> PromptResponse:
39
+ """Generate a prompt for continuing a conversation.
40
+
41
+ This endpoint takes a topic and/or timeframe and generates a prompt with
42
+ relevant context from the knowledge base.
43
+
44
+ Args:
45
+ request: The request parameters
46
+
47
+ Returns:
48
+ Formatted continuation prompt with context
49
+ """
50
+ logger.info(
51
+ f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
52
+ )
53
+
54
+ since = parse_timeframe(request.timeframe) if request.timeframe else None
55
+
56
+ # Initialize search results
57
+ search_results = []
58
+
59
+ # Get data needed for template
60
+ if request.topic:
61
+ query = SearchQuery(text=request.topic, after_date=request.timeframe)
62
+ results = await search_service.search(query, limit=request.search_items_limit)
63
+ search_results = await to_search_results(entity_service, results)
64
+
65
+ # Build context from results
66
+ all_hierarchical_results = []
67
+ for result in search_results:
68
+ if hasattr(result, "permalink") and result.permalink:
69
+ # Get hierarchical context using the new dataclass-based approach
70
+ context_result = await context_service.build_context(
71
+ result.permalink,
72
+ depth=request.depth,
73
+ since=since,
74
+ max_related=request.related_items_limit,
75
+ include_observations=True, # Include observations for entities
76
+ )
77
+
78
+ # Process results into the schema format
79
+ graph_context = await to_graph_context(
80
+ context_result, entity_repository=entity_repository
81
+ )
82
+
83
+ # Add results to our collection (limit to top results for each permalink)
84
+ if graph_context.results:
85
+ all_hierarchical_results.extend(graph_context.results[:3])
86
+
87
+ # Limit to a reasonable number of total results
88
+ all_hierarchical_results = all_hierarchical_results[:10]
89
+
90
+ template_context = {
91
+ "topic": request.topic,
92
+ "timeframe": request.timeframe,
93
+ "hierarchical_results": all_hierarchical_results,
94
+ "has_results": len(all_hierarchical_results) > 0,
95
+ }
96
+ else:
97
+ # If no topic, get recent activity
98
+ context_result = await context_service.build_context(
99
+ types=[SearchItemType.ENTITY],
100
+ depth=request.depth,
101
+ since=since,
102
+ max_related=request.related_items_limit,
103
+ include_observations=True,
104
+ )
105
+ recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
106
+
107
+ hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
108
+
109
+ template_context = {
110
+ "topic": f"Recent Activity from ({request.timeframe})",
111
+ "timeframe": request.timeframe,
112
+ "hierarchical_results": hierarchical_results,
113
+ "has_results": len(hierarchical_results) > 0,
114
+ }
115
+
116
+ try:
117
+ # Render template
118
+ rendered_prompt = await template_loader.render(
119
+ "prompts/continue_conversation.hbs", template_context
120
+ )
121
+
122
+ # Calculate metadata
123
+ # Count items of different types
124
+ observation_count = 0
125
+ relation_count = 0
126
+ entity_count = 0
127
+
128
+ # Get the hierarchical results from the template context
129
+ hierarchical_results_for_count = template_context.get("hierarchical_results", [])
130
+
131
+ # For topic-based search
132
+ if request.topic:
133
+ for item in hierarchical_results_for_count:
134
+ if hasattr(item, "observations"):
135
+ observation_count += len(item.observations) if item.observations else 0
136
+
137
+ if hasattr(item, "related_results"):
138
+ for related in item.related_results or []:
139
+ if hasattr(related, "type"):
140
+ if related.type == "relation":
141
+ relation_count += 1
142
+ elif related.type == "entity": # pragma: no cover
143
+ entity_count += 1 # pragma: no cover
144
+ # For recent activity
145
+ else:
146
+ for item in hierarchical_results_for_count:
147
+ if hasattr(item, "observations"):
148
+ observation_count += len(item.observations) if item.observations else 0
149
+
150
+ if hasattr(item, "related_results"):
151
+ for related in item.related_results or []:
152
+ if hasattr(related, "type"):
153
+ if related.type == "relation":
154
+ relation_count += 1
155
+ elif related.type == "entity": # pragma: no cover
156
+ entity_count += 1 # pragma: no cover
157
+
158
+ # Build metadata
159
+ metadata = {
160
+ "query": request.topic,
161
+ "timeframe": request.timeframe,
162
+ "search_count": len(search_results)
163
+ if request.topic
164
+ else 0, # Original search results count
165
+ "context_count": len(hierarchical_results_for_count),
166
+ "observation_count": observation_count,
167
+ "relation_count": relation_count,
168
+ "total_items": (
169
+ len(hierarchical_results_for_count)
170
+ + observation_count
171
+ + relation_count
172
+ + entity_count
173
+ ),
174
+ "search_limit": request.search_items_limit,
175
+ "context_depth": request.depth,
176
+ "related_limit": request.related_items_limit,
177
+ "generated_at": datetime.now(timezone.utc).isoformat(),
178
+ }
179
+
180
+ prompt_metadata = PromptMetadata(**metadata)
181
+
182
+ return PromptResponse(
183
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
184
+ )
185
+ except Exception as e:
186
+ logger.error(f"Error rendering continue conversation template: {e}")
187
+ raise HTTPException(
188
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
189
+ detail=f"Error rendering prompt template: {str(e)}",
190
+ )
191
+
192
+
193
+ @router.post("/search", response_model=PromptResponse)
194
+ async def search_prompt(
195
+ search_service: SearchServiceDep,
196
+ entity_service: EntityServiceDep,
197
+ request: SearchPromptRequest,
198
+ page: int = 1,
199
+ page_size: int = 10,
200
+ ) -> PromptResponse:
201
+ """Generate a prompt for search results.
202
+
203
+ This endpoint takes a search query and formats the results into a helpful
204
+ prompt with context and suggestions.
205
+
206
+ Args:
207
+ request: The search parameters
208
+ page: The page number for pagination
209
+ page_size: The number of results per page, defaults to 10
210
+
211
+ Returns:
212
+ Formatted search results prompt with context
213
+ """
214
+ logger.info(f"Generating search prompt, query: {request.query}, timeframe: {request.timeframe}")
215
+
216
+ limit = page_size
217
+ offset = (page - 1) * page_size
218
+
219
+ query = SearchQuery(text=request.query, after_date=request.timeframe)
220
+ results = await search_service.search(query, limit=limit, offset=offset)
221
+ search_results = await to_search_results(entity_service, results)
222
+
223
+ template_context = {
224
+ "query": request.query,
225
+ "timeframe": request.timeframe,
226
+ "results": search_results,
227
+ "has_results": len(search_results) > 0,
228
+ "result_count": len(search_results),
229
+ }
230
+
231
+ try:
232
+ # Render template
233
+ rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
234
+
235
+ # Build metadata
236
+ metadata = {
237
+ "query": request.query,
238
+ "timeframe": request.timeframe,
239
+ "search_count": len(search_results),
240
+ "context_count": len(search_results),
241
+ "observation_count": 0, # Search results don't include observations
242
+ "relation_count": 0, # Search results don't include relations
243
+ "total_items": len(search_results),
244
+ "search_limit": limit,
245
+ "context_depth": 0, # No context depth for basic search
246
+ "related_limit": 0, # No related items for basic search
247
+ "generated_at": datetime.now(timezone.utc).isoformat(),
248
+ }
249
+
250
+ prompt_metadata = PromptMetadata(**metadata)
251
+
252
+ return PromptResponse(
253
+ prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
254
+ )
255
+ except Exception as e:
256
+ logger.error(f"Error rendering search template: {e}")
257
+ raise HTTPException(
258
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
259
+ detail=f"Error rendering prompt template: {str(e)}",
260
+ )
@@ -0,0 +1,249 @@
1
+ """Routes for getting entity content."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Annotated, Union
6
+
7
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from loguru import logger
10
+
11
+ from basic_memory.deps import (
12
+ ProjectConfigDep,
13
+ LinkResolverDep,
14
+ SearchServiceDep,
15
+ EntityServiceDep,
16
+ FileServiceDep,
17
+ EntityRepositoryDep,
18
+ )
19
+ from basic_memory.repository.search_repository import SearchIndexRow
20
+ from basic_memory.schemas.memory import normalize_memory_url
21
+ from basic_memory.schemas.search import SearchQuery, SearchItemType
22
+ from basic_memory.models.knowledge import Entity as EntityModel
23
+ from datetime import datetime
24
+
25
+ router = APIRouter(prefix="/resource", tags=["resources"])
26
+
27
+
28
+ def _mtime_to_datetime(entity: EntityModel) -> datetime:
29
+ """Convert entity mtime (file modification time) to datetime.
30
+
31
+ Returns the file's actual modification time, falling back to updated_at
32
+ if mtime is not available.
33
+ """
34
+ if entity.mtime:
35
+ return datetime.fromtimestamp(entity.mtime).astimezone()
36
+ return entity.updated_at
37
+
38
+
39
+ def get_entity_ids(item: SearchIndexRow) -> set[int]:
40
+ match item.type:
41
+ case SearchItemType.ENTITY:
42
+ return {item.id}
43
+ case SearchItemType.OBSERVATION:
44
+ return {item.entity_id} # pyright: ignore [reportReturnType]
45
+ case SearchItemType.RELATION:
46
+ from_entity = item.from_id
47
+ to_entity = item.to_id # pyright: ignore [reportReturnType]
48
+ return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
49
+ case _: # pragma: no cover
50
+ raise ValueError(f"Unexpected type: {item.type}")
51
+
52
+
53
+ @router.get("/{identifier:path}", response_model=None)
54
+ async def get_resource_content(
55
+ config: ProjectConfigDep,
56
+ link_resolver: LinkResolverDep,
57
+ search_service: SearchServiceDep,
58
+ entity_service: EntityServiceDep,
59
+ file_service: FileServiceDep,
60
+ background_tasks: BackgroundTasks,
61
+ identifier: str,
62
+ page: int = 1,
63
+ page_size: int = 10,
64
+ ) -> Union[Response, FileResponse]:
65
+ """Get resource content by identifier: name or permalink."""
66
+ logger.debug(f"Getting content for: {identifier}")
67
+
68
+ # Find single entity by permalink
69
+ entity = await link_resolver.resolve_link(identifier)
70
+ results = [entity] if entity else []
71
+
72
+ # pagination for multiple results
73
+ limit = page_size
74
+ offset = (page - 1) * page_size
75
+
76
+ # search using the identifier as a permalink
77
+ if not results:
78
+ # if the identifier contains a wildcard, use GLOB search
79
+ query = (
80
+ SearchQuery(permalink_match=identifier)
81
+ if "*" in identifier
82
+ else SearchQuery(permalink=identifier)
83
+ )
84
+ search_results = await search_service.search(query, limit, offset)
85
+ if not search_results:
86
+ raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
87
+
88
+ # get the deduplicated entities related to the search results
89
+ entity_ids = {id for result in search_results for id in get_entity_ids(result)}
90
+ results = await entity_service.get_entities_by_id(list(entity_ids))
91
+
92
+ # return single response
93
+ if len(results) == 1:
94
+ entity = results[0]
95
+ # Check file exists via file_service (for cloud compatibility)
96
+ if not await file_service.exists(entity.file_path):
97
+ raise HTTPException(
98
+ status_code=404,
99
+ detail=f"File not found: {entity.file_path}",
100
+ )
101
+ # Read content via file_service as bytes (works with both local and S3)
102
+ content = await file_service.read_file_bytes(entity.file_path)
103
+ content_type = file_service.content_type(entity.file_path)
104
+ return Response(content=content, media_type=content_type)
105
+
106
+ # for multiple files, initialize a temporary file for writing the results
107
+ with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
108
+ temp_file_path = tmp_file.name
109
+
110
+ for result in results:
111
+ # Read content for each entity
112
+ content = await file_service.read_entity_content(result)
113
+ memory_url = normalize_memory_url(result.permalink)
114
+ modified_date = _mtime_to_datetime(result).isoformat()
115
+ checksum = result.checksum[:8] if result.checksum else ""
116
+
117
+ # Prepare the delimited content
118
+ response_content = f"--- {memory_url} {modified_date} {checksum}\n"
119
+ response_content += f"\n{content}\n"
120
+ response_content += "\n"
121
+
122
+ # Write content directly to the temporary file in append mode
123
+ tmp_file.write(response_content)
124
+
125
+ # Ensure all content is written to disk
126
+ tmp_file.flush()
127
+
128
+ # Schedule the temporary file to be deleted after the response
129
+ background_tasks.add_task(cleanup_temp_file, temp_file_path)
130
+
131
+ # Return the file response
132
+ return FileResponse(path=temp_file_path)
133
+
134
+
135
+ def cleanup_temp_file(file_path: str):
136
+ """Delete the temporary file."""
137
+ try:
138
+ Path(file_path).unlink() # Deletes the file
139
+ logger.debug(f"Temporary file deleted: {file_path}")
140
+ except Exception as e: # pragma: no cover
141
+ logger.error(f"Error deleting temporary file {file_path}: {e}")
142
+
143
+
144
+ @router.put("/{file_path:path}")
145
+ async def write_resource(
146
+ config: ProjectConfigDep,
147
+ file_service: FileServiceDep,
148
+ entity_repository: EntityRepositoryDep,
149
+ search_service: SearchServiceDep,
150
+ file_path: str,
151
+ content: Annotated[str, Body()],
152
+ ) -> JSONResponse:
153
+ """Write content to a file in the project.
154
+
155
+ This endpoint allows writing content directly to a file in the project.
156
+ Also creates an entity record and indexes the file for search.
157
+
158
+ Args:
159
+ file_path: Path to write to, relative to project root
160
+ request: Contains the content to write
161
+
162
+ Returns:
163
+ JSON response with file information
164
+ """
165
+ try:
166
+ # Get content from request body
167
+
168
+ # Defensive type checking: ensure content is a string
169
+ # FastAPI should validate this, but if a dict somehow gets through
170
+ # (e.g., via JSON body parsing), we need to catch it here
171
+ if isinstance(content, dict):
172
+ logger.error(
173
+ f"Error writing resource {file_path}: "
174
+ f"content is a dict, expected string. Keys: {list(content.keys())}"
175
+ )
176
+ raise HTTPException(
177
+ status_code=400,
178
+ detail="content must be a string, not a dict. "
179
+ "Ensure request body is sent as raw string content, not JSON object.",
180
+ )
181
+
182
+ # Ensure it's UTF-8 string content
183
+ if isinstance(content, bytes): # pragma: no cover
184
+ content_str = content.decode("utf-8")
185
+ else:
186
+ content_str = str(content)
187
+
188
+ # Cloud compatibility: do not assume a local filesystem path structure.
189
+ # Delegate directory creation + writes to the configured FileService (local or S3).
190
+ await file_service.ensure_directory(Path(file_path).parent)
191
+ checksum = await file_service.write_file(file_path, content_str)
192
+
193
+ # Get file info
194
+ file_metadata = await file_service.get_file_metadata(file_path)
195
+
196
+ # Determine file details
197
+ file_name = Path(file_path).name
198
+ content_type = file_service.content_type(file_path)
199
+
200
+ entity_type = "canvas" if file_path.endswith(".canvas") else "file"
201
+
202
+ # Check if entity already exists
203
+ existing_entity = await entity_repository.get_by_file_path(file_path)
204
+
205
+ if existing_entity:
206
+ # Update existing entity
207
+ entity = await entity_repository.update(
208
+ existing_entity.id,
209
+ {
210
+ "title": file_name,
211
+ "entity_type": entity_type,
212
+ "content_type": content_type,
213
+ "file_path": file_path,
214
+ "checksum": checksum,
215
+ "updated_at": file_metadata.modified_at,
216
+ },
217
+ )
218
+ status_code = 200
219
+ else:
220
+ # Create a new entity model
221
+ entity = EntityModel(
222
+ title=file_name,
223
+ entity_type=entity_type,
224
+ content_type=content_type,
225
+ file_path=file_path,
226
+ checksum=checksum,
227
+ created_at=file_metadata.created_at,
228
+ updated_at=file_metadata.modified_at,
229
+ )
230
+ entity = await entity_repository.add(entity)
231
+ status_code = 201
232
+
233
+ # Index the file for search
234
+ await search_service.index_entity(entity) # pyright: ignore
235
+
236
+ # Return success response
237
+ return JSONResponse(
238
+ status_code=status_code,
239
+ content={
240
+ "file_path": file_path,
241
+ "checksum": checksum,
242
+ "size": file_metadata.size,
243
+ "created_at": file_metadata.created_at.timestamp(),
244
+ "modified_at": file_metadata.modified_at.timestamp(),
245
+ },
246
+ )
247
+ except Exception as e: # pragma: no cover
248
+ logger.error(f"Error writing resource {file_path}: {e}")
249
+ raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
@@ -0,0 +1,36 @@
1
+ """Router for search operations."""
2
+
3
+ from fastapi import APIRouter, BackgroundTasks
4
+
5
+ from basic_memory.api.routers.utils import to_search_results
6
+ from basic_memory.schemas.search import SearchQuery, SearchResponse
7
+ from basic_memory.deps import SearchServiceDep, EntityServiceDep
8
+
9
+ router = APIRouter(prefix="/search", tags=["search"])
10
+
11
+
12
+ @router.post("/", response_model=SearchResponse)
13
+ async def search(
14
+ query: SearchQuery,
15
+ search_service: SearchServiceDep,
16
+ entity_service: EntityServiceDep,
17
+ page: int = 1,
18
+ page_size: int = 10,
19
+ ):
20
+ """Search across all knowledge and documents."""
21
+ limit = page_size
22
+ offset = (page - 1) * page_size
23
+ results = await search_service.search(query, limit=limit, offset=offset)
24
+ search_results = await to_search_results(entity_service, results)
25
+ return SearchResponse(
26
+ results=search_results,
27
+ current_page=page,
28
+ page_size=page_size,
29
+ )
30
+
31
+
32
+ @router.post("/reindex")
33
+ async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
34
+ """Recreate and populate the search index."""
35
+ await search_service.reindex_all(background_tasks=background_tasks)
36
+ return {"status": "ok", "message": "Reindex initiated"}