basic-memory 0.7.0__py3-none-any.whl → 0.17.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -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
+ )
@@ -2,9 +2,10 @@
2
2
 
3
3
  import tempfile
4
4
  from pathlib import Path
5
+ from typing import Annotated, Union
5
6
 
6
- from fastapi import APIRouter, HTTPException, BackgroundTasks
7
- from fastapi.responses import FileResponse
7
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
8
+ from fastapi.responses import FileResponse, JSONResponse
8
9
  from loguru import logger
9
10
 
10
11
  from basic_memory.deps import (
@@ -13,14 +14,28 @@ from basic_memory.deps import (
13
14
  SearchServiceDep,
14
15
  EntityServiceDep,
15
16
  FileServiceDep,
17
+ EntityRepositoryDep,
16
18
  )
17
19
  from basic_memory.repository.search_repository import SearchIndexRow
18
20
  from basic_memory.schemas.memory import normalize_memory_url
19
21
  from basic_memory.schemas.search import SearchQuery, SearchItemType
22
+ from basic_memory.models.knowledge import Entity as EntityModel
23
+ from datetime import datetime
20
24
 
21
25
  router = APIRouter(prefix="/resource", tags=["resources"])
22
26
 
23
27
 
28
+ def _mtime_to_datetime(entity: EntityModel) -> datetime:
29
+ """Convert entity mtime (file modification time) to datetime.
30
+
31
+ Returns the file's actual modification time, falling back to updated_at
32
+ if mtime is not available.
33
+ """
34
+ if entity.mtime: # pragma: no cover
35
+ return datetime.fromtimestamp(entity.mtime).astimezone() # pragma: no cover
36
+ return entity.updated_at
37
+
38
+
24
39
  def get_entity_ids(item: SearchIndexRow) -> set[int]:
25
40
  match item.type:
26
41
  case SearchItemType.ENTITY:
@@ -35,7 +50,7 @@ def get_entity_ids(item: SearchIndexRow) -> set[int]:
35
50
  raise ValueError(f"Unexpected type: {item.type}")
36
51
 
37
52
 
38
- @router.get("/{identifier:path}")
53
+ @router.get("/{identifier:path}", response_model=None)
39
54
  async def get_resource_content(
40
55
  config: ProjectConfigDep,
41
56
  link_resolver: LinkResolverDep,
@@ -46,7 +61,7 @@ async def get_resource_content(
46
61
  identifier: str,
47
62
  page: int = 1,
48
63
  page_size: int = 10,
49
- ) -> FileResponse:
64
+ ) -> Union[Response, FileResponse]:
50
65
  """Get resource content by identifier: name or permalink."""
51
66
  logger.debug(f"Getting content for: {identifier}")
52
67
 
@@ -77,13 +92,16 @@ async def get_resource_content(
77
92
  # return single response
78
93
  if len(results) == 1:
79
94
  entity = results[0]
80
- file_path = Path(f"{config.home}/{entity.file_path}")
81
- if not file_path.exists():
95
+ # Check file exists via file_service (for cloud compatibility)
96
+ if not await file_service.exists(entity.file_path):
82
97
  raise HTTPException(
83
98
  status_code=404,
84
- detail=f"File not found: {file_path}",
99
+ detail=f"File not found: {entity.file_path}",
85
100
  )
86
- return FileResponse(path=file_path)
101
+ # Read content via file_service as bytes (works with both local and S3)
102
+ content = await file_service.read_file_bytes(entity.file_path)
103
+ content_type = file_service.content_type(entity.file_path)
104
+ return Response(content=content, media_type=content_type)
87
105
 
88
106
  # for multiple files, initialize a temporary file for writing the results
89
107
  with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
@@ -93,9 +111,8 @@ async def get_resource_content(
93
111
  # Read content for each entity
94
112
  content = await file_service.read_entity_content(result)
95
113
  memory_url = normalize_memory_url(result.permalink)
96
- modified_date = result.updated_at.isoformat()
97
- assert result.checksum
98
- checksum = result.checksum[:8]
114
+ modified_date = _mtime_to_datetime(result).isoformat()
115
+ checksum = result.checksum[:8] if result.checksum else ""
99
116
 
100
117
  # Prepare the delimited content
101
118
  response_content = f"--- {memory_url} {modified_date} {checksum}\n"
@@ -122,3 +139,111 @@ def cleanup_temp_file(file_path: str):
122
139
  logger.debug(f"Temporary file deleted: {file_path}")
123
140
  except Exception as e: # pragma: no cover
124
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( # pragma: no cover
173
+ f"Error writing resource {file_path}: "
174
+ f"content is a dict, expected string. Keys: {list(content.keys())}"
175
+ )
176
+ raise HTTPException( # pragma: no cover
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)}")
@@ -1,11 +1,10 @@
1
1
  """Router for search operations."""
2
2
 
3
- from dataclasses import asdict
4
-
5
3
  from fastapi import APIRouter, BackgroundTasks
6
4
 
7
- from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
8
- from basic_memory.deps import SearchServiceDep
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
9
8
 
10
9
  router = APIRouter(prefix="/search", tags=["search"])
11
10
 
@@ -14,6 +13,7 @@ router = APIRouter(prefix="/search", tags=["search"])
14
13
  async def search(
15
14
  query: SearchQuery,
16
15
  search_service: SearchServiceDep,
16
+ entity_service: EntityServiceDep,
17
17
  page: int = 1,
18
18
  page_size: int = 10,
19
19
  ):
@@ -21,7 +21,7 @@ async def search(
21
21
  limit = page_size
22
22
  offset = (page - 1) * page_size
23
23
  results = await search_service.search(query, limit=limit, offset=offset)
24
- search_results = [SearchResult.model_validate(asdict(r)) for r in results]
24
+ search_results = await to_search_results(entity_service, results)
25
25
  return SearchResponse(
26
26
  results=search_results,
27
27
  current_page=page,
@@ -0,0 +1,169 @@
1
+ from typing import Optional, List
2
+
3
+ from basic_memory.repository import EntityRepository
4
+ from basic_memory.repository.search_repository import SearchIndexRow
5
+ from basic_memory.schemas.memory import (
6
+ EntitySummary,
7
+ ObservationSummary,
8
+ RelationSummary,
9
+ MemoryMetadata,
10
+ GraphContext,
11
+ ContextResult,
12
+ )
13
+ from basic_memory.schemas.search import SearchItemType, SearchResult
14
+ from basic_memory.services import EntityService
15
+ from basic_memory.services.context_service import (
16
+ ContextResultRow,
17
+ ContextResult as ServiceContextResult,
18
+ )
19
+
20
+
21
+ async def to_graph_context(
22
+ context_result: ServiceContextResult,
23
+ entity_repository: EntityRepository,
24
+ page: Optional[int] = None,
25
+ page_size: Optional[int] = None,
26
+ ):
27
+ # First pass: collect all entity IDs needed for relations
28
+ entity_ids_needed: set[int] = set()
29
+ for context_item in context_result.results:
30
+ for item in (
31
+ [context_item.primary_result] + context_item.observations + context_item.related_results
32
+ ):
33
+ if item.type == SearchItemType.RELATION:
34
+ if item.from_id: # pyright: ignore
35
+ entity_ids_needed.add(item.from_id) # pyright: ignore
36
+ if item.to_id:
37
+ entity_ids_needed.add(item.to_id)
38
+
39
+ # Batch fetch all entities at once
40
+ entity_lookup: dict[int, str] = {}
41
+ if entity_ids_needed:
42
+ entities = await entity_repository.find_by_ids(list(entity_ids_needed))
43
+ entity_lookup = {e.id: e.title for e in entities}
44
+
45
+ # Helper function to convert items to summaries
46
+ def to_summary(item: SearchIndexRow | ContextResultRow):
47
+ match item.type:
48
+ case SearchItemType.ENTITY:
49
+ return EntitySummary(
50
+ entity_id=item.id,
51
+ title=item.title, # pyright: ignore
52
+ permalink=item.permalink,
53
+ content=item.content,
54
+ file_path=item.file_path,
55
+ created_at=item.created_at,
56
+ )
57
+ case SearchItemType.OBSERVATION:
58
+ return ObservationSummary(
59
+ observation_id=item.id,
60
+ entity_id=item.entity_id, # pyright: ignore
61
+ title=item.title, # pyright: ignore
62
+ file_path=item.file_path,
63
+ category=item.category, # pyright: ignore
64
+ content=item.content, # pyright: ignore
65
+ permalink=item.permalink, # pyright: ignore
66
+ created_at=item.created_at,
67
+ )
68
+ case SearchItemType.RELATION:
69
+ from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
70
+ to_title = entity_lookup.get(item.to_id) if item.to_id else None
71
+ return RelationSummary(
72
+ relation_id=item.id,
73
+ entity_id=item.entity_id, # pyright: ignore
74
+ title=item.title, # pyright: ignore
75
+ file_path=item.file_path,
76
+ permalink=item.permalink, # pyright: ignore
77
+ relation_type=item.relation_type, # pyright: ignore
78
+ from_entity=from_title,
79
+ from_entity_id=item.from_id, # pyright: ignore
80
+ to_entity=to_title,
81
+ to_entity_id=item.to_id,
82
+ created_at=item.created_at,
83
+ )
84
+ case _: # pragma: no cover
85
+ raise ValueError(f"Unexpected type: {item.type}")
86
+
87
+ # Process the hierarchical results
88
+ hierarchical_results = []
89
+ for context_item in context_result.results:
90
+ # Process primary result
91
+ primary_result = to_summary(context_item.primary_result)
92
+
93
+ # Process observations (always ObservationSummary, validated by context_service)
94
+ observations = [to_summary(obs) for obs in context_item.observations]
95
+
96
+ # Process related results
97
+ related = [to_summary(rel) for rel in context_item.related_results]
98
+
99
+ # Add to hierarchical results
100
+ hierarchical_results.append(
101
+ ContextResult(
102
+ primary_result=primary_result,
103
+ observations=observations, # pyright: ignore[reportArgumentType]
104
+ related_results=related,
105
+ )
106
+ )
107
+
108
+ # Create schema metadata from service metadata
109
+ metadata = MemoryMetadata(
110
+ uri=context_result.metadata.uri,
111
+ types=context_result.metadata.types,
112
+ depth=context_result.metadata.depth,
113
+ timeframe=context_result.metadata.timeframe,
114
+ generated_at=context_result.metadata.generated_at,
115
+ primary_count=context_result.metadata.primary_count,
116
+ related_count=context_result.metadata.related_count,
117
+ total_results=context_result.metadata.primary_count + context_result.metadata.related_count,
118
+ total_relations=context_result.metadata.total_relations,
119
+ total_observations=context_result.metadata.total_observations,
120
+ )
121
+
122
+ # Return new GraphContext with just hierarchical results
123
+ return GraphContext(
124
+ results=hierarchical_results,
125
+ metadata=metadata,
126
+ page=page,
127
+ page_size=page_size,
128
+ )
129
+
130
+
131
+ async def to_search_results(entity_service: EntityService, results: List[SearchIndexRow]):
132
+ search_results = []
133
+ for r in results:
134
+ entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
135
+
136
+ # Determine which IDs to set based on type
137
+ entity_id = None
138
+ observation_id = None
139
+ relation_id = None
140
+
141
+ if r.type == SearchItemType.ENTITY:
142
+ entity_id = r.id
143
+ elif r.type == SearchItemType.OBSERVATION:
144
+ observation_id = r.id
145
+ entity_id = r.entity_id # Parent entity
146
+ elif r.type == SearchItemType.RELATION:
147
+ relation_id = r.id
148
+ entity_id = r.entity_id # Parent entity
149
+
150
+ search_results.append(
151
+ SearchResult(
152
+ title=r.title, # pyright: ignore
153
+ type=r.type, # pyright: ignore
154
+ permalink=r.permalink,
155
+ score=r.score, # pyright: ignore
156
+ entity=entities[0].permalink if entities else None,
157
+ content=r.content,
158
+ file_path=r.file_path,
159
+ metadata=r.metadata,
160
+ entity_id=entity_id,
161
+ observation_id=observation_id,
162
+ relation_id=relation_id,
163
+ category=r.category,
164
+ from_entity=entities[0].permalink if entities else None,
165
+ to_entity=entities[1].permalink if len(entities) > 1 else None,
166
+ relation_type=r.relation_type,
167
+ )
168
+ )
169
+ return search_results