basic-memory 0.2.12__py3-none-any.whl → 0.16.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.

Potentially problematic release.


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

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,406 @@
1
+ """Router for project management."""
2
+
3
+ import os
4
+ from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
5
+ from typing import Optional
6
+ from loguru import logger
7
+
8
+ from basic_memory.deps import (
9
+ ProjectConfigDep,
10
+ ProjectServiceDep,
11
+ ProjectPathDep,
12
+ SyncServiceDep,
13
+ )
14
+ from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
15
+ from basic_memory.schemas.project_info import (
16
+ ProjectList,
17
+ ProjectItem,
18
+ ProjectInfoRequest,
19
+ ProjectStatusResponse,
20
+ )
21
+ from basic_memory.utils import normalize_project_path
22
+
23
+ # Router for resources in a specific project
24
+ # The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
25
+ project_router = APIRouter(prefix="/project", tags=["project"])
26
+
27
+ # Router for managing project resources
28
+ project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
29
+
30
+
31
+ @project_router.get("/info", response_model=ProjectInfoResponse)
32
+ async def get_project_info(
33
+ project_service: ProjectServiceDep,
34
+ project: ProjectPathDep,
35
+ ) -> ProjectInfoResponse:
36
+ """Get comprehensive information about the specified Basic Memory project."""
37
+ return await project_service.get_project_info(project)
38
+
39
+
40
+ @project_router.get("/item", response_model=ProjectItem)
41
+ async def get_project(
42
+ project_service: ProjectServiceDep,
43
+ project: ProjectPathDep,
44
+ ) -> ProjectItem:
45
+ """Get bassic info about the specified Basic Memory project."""
46
+ found_project = await project_service.get_project(project)
47
+ if not found_project:
48
+ raise HTTPException(
49
+ status_code=404, detail=f"Project: '{project}' does not exist"
50
+ ) # pragma: no cover
51
+
52
+ return ProjectItem(
53
+ name=found_project.name,
54
+ path=normalize_project_path(found_project.path),
55
+ is_default=found_project.is_default or False,
56
+ )
57
+
58
+
59
+ # Update a project
60
+ @project_router.patch("/{name}", response_model=ProjectStatusResponse)
61
+ async def update_project(
62
+ project_service: ProjectServiceDep,
63
+ name: str = Path(..., description="Name of the project to update"),
64
+ path: Optional[str] = Body(None, description="New absolute path for the project"),
65
+ is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
66
+ ) -> ProjectStatusResponse:
67
+ """Update a project's information in configuration and database.
68
+
69
+ Args:
70
+ name: The name of the project to update
71
+ path: Optional new absolute path for the project
72
+ is_active: Optional status update for the project
73
+
74
+ Returns:
75
+ Response confirming the project was updated
76
+ """
77
+ try:
78
+ # Validate that path is absolute if provided
79
+ if path and not os.path.isabs(path):
80
+ raise HTTPException(status_code=400, detail="Path must be absolute")
81
+
82
+ # Get original project info for the response
83
+ old_project_info = ProjectItem(
84
+ name=name,
85
+ path=project_service.projects.get(name, ""),
86
+ )
87
+
88
+ if path:
89
+ await project_service.move_project(name, path)
90
+ elif is_active is not None:
91
+ await project_service.update_project(name, is_active=is_active)
92
+
93
+ # Get updated project info
94
+ updated_path = path if path else project_service.projects.get(name, "")
95
+
96
+ return ProjectStatusResponse(
97
+ message=f"Project '{name}' updated successfully",
98
+ status="success",
99
+ default=(name == project_service.default_project),
100
+ old_project=old_project_info,
101
+ new_project=ProjectItem(name=name, path=updated_path),
102
+ )
103
+ except ValueError as e:
104
+ raise HTTPException(status_code=400, detail=str(e))
105
+
106
+
107
+ # Sync project filesystem
108
+ @project_router.post("/sync")
109
+ async def sync_project(
110
+ background_tasks: BackgroundTasks,
111
+ sync_service: SyncServiceDep,
112
+ project_config: ProjectConfigDep,
113
+ force_full: bool = Query(
114
+ False, description="Force full scan, bypassing watermark optimization"
115
+ ),
116
+ run_in_background: bool = Query(True, description="Run in background"),
117
+ ):
118
+ """Force project filesystem sync to database.
119
+
120
+ Scans the project directory and updates the database with any new or modified files.
121
+
122
+ Args:
123
+ background_tasks: FastAPI background tasks
124
+ sync_service: Sync service for this project
125
+ project_config: Project configuration
126
+ force_full: If True, force a full scan even if watermark exists
127
+ run_in_background: If True, run sync in background and return immediately
128
+
129
+ Returns:
130
+ Response confirming sync was initiated (background) or SyncReportResponse (foreground)
131
+ """
132
+ if run_in_background:
133
+ background_tasks.add_task(
134
+ sync_service.sync, project_config.home, project_config.name, force_full=force_full
135
+ )
136
+ logger.info(
137
+ f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
138
+ )
139
+
140
+ return {
141
+ "status": "sync_started",
142
+ "message": f"Filesystem sync initiated for project '{project_config.name}'",
143
+ }
144
+ else:
145
+ report = await sync_service.sync(
146
+ project_config.home, project_config.name, force_full=force_full
147
+ )
148
+ logger.info(
149
+ f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
150
+ )
151
+ return SyncReportResponse.from_sync_report(report)
152
+
153
+
154
+ @project_router.post("/status", response_model=SyncReportResponse)
155
+ async def project_sync_status(
156
+ sync_service: SyncServiceDep,
157
+ project_config: ProjectConfigDep,
158
+ ) -> SyncReportResponse:
159
+ """Scan directory for changes compared to database state.
160
+
161
+ Args:
162
+ sync_service: Sync service for this project
163
+ project_config: Project configuration
164
+
165
+ Returns:
166
+ Scan report with details on files that need syncing
167
+ """
168
+ logger.info(f"Scanning filesystem for project: {project_config.name}")
169
+ sync_report = await sync_service.scan(project_config.home)
170
+
171
+ return SyncReportResponse.from_sync_report(sync_report)
172
+
173
+
174
+ # List all available projects
175
+ @project_resource_router.get("/projects", response_model=ProjectList)
176
+ async def list_projects(
177
+ project_service: ProjectServiceDep,
178
+ ) -> ProjectList:
179
+ """List all configured projects.
180
+
181
+ Returns:
182
+ A list of all projects with metadata
183
+ """
184
+ projects = await project_service.list_projects()
185
+ default_project = project_service.default_project
186
+
187
+ project_items = [
188
+ ProjectItem(
189
+ name=project.name,
190
+ path=normalize_project_path(project.path),
191
+ is_default=project.is_default or False,
192
+ )
193
+ for project in projects
194
+ ]
195
+
196
+ return ProjectList(
197
+ projects=project_items,
198
+ default_project=default_project,
199
+ )
200
+
201
+
202
+ # Add a new project
203
+ @project_resource_router.post("/projects", response_model=ProjectStatusResponse, status_code=201)
204
+ async def add_project(
205
+ response: Response,
206
+ project_data: ProjectInfoRequest,
207
+ project_service: ProjectServiceDep,
208
+ ) -> ProjectStatusResponse:
209
+ """Add a new project to configuration and database.
210
+
211
+ Args:
212
+ project_data: The project name and path, with option to set as default
213
+
214
+ Returns:
215
+ Response confirming the project was added
216
+ """
217
+ # Check if project already exists before attempting to add
218
+ existing_project = await project_service.get_project(project_data.name)
219
+ if existing_project:
220
+ # Project exists - check if paths match for true idempotency
221
+ # Normalize paths for comparison (resolve symlinks, etc.)
222
+ from pathlib import Path
223
+
224
+ requested_path = Path(project_data.path).resolve()
225
+ existing_path = Path(existing_project.path).resolve()
226
+
227
+ if requested_path == existing_path:
228
+ # Same name, same path - return 200 OK (idempotent)
229
+ response.status_code = 200
230
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
231
+ message=f"Project '{project_data.name}' already exists",
232
+ status="success",
233
+ default=existing_project.is_default or False,
234
+ new_project=ProjectItem(
235
+ name=existing_project.name,
236
+ path=existing_project.path,
237
+ is_default=existing_project.is_default or False,
238
+ ),
239
+ )
240
+ else:
241
+ # Same name, different path - this is an error
242
+ raise HTTPException(
243
+ status_code=400,
244
+ detail=f"Project '{project_data.name}' already exists with different path. Existing: {existing_project.path}, Requested: {project_data.path}",
245
+ )
246
+
247
+ try: # pragma: no cover
248
+ # The service layer now handles cloud mode validation and path sanitization
249
+ await project_service.add_project(
250
+ project_data.name, project_data.path, set_default=project_data.set_default
251
+ )
252
+
253
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
254
+ message=f"Project '{project_data.name}' added successfully",
255
+ status="success",
256
+ default=project_data.set_default,
257
+ new_project=ProjectItem(
258
+ name=project_data.name, path=project_data.path, is_default=project_data.set_default
259
+ ),
260
+ )
261
+ except ValueError as e: # pragma: no cover
262
+ raise HTTPException(status_code=400, detail=str(e))
263
+
264
+
265
+ # Remove a project
266
+ @project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
267
+ async def remove_project(
268
+ project_service: ProjectServiceDep,
269
+ name: str = Path(..., description="Name of the project to remove"),
270
+ delete_notes: bool = Query(
271
+ False, description="If True, delete project directory from filesystem"
272
+ ),
273
+ ) -> ProjectStatusResponse:
274
+ """Remove a project from configuration and database.
275
+
276
+ Args:
277
+ name: The name of the project to remove
278
+ delete_notes: If True, delete the project directory from the filesystem
279
+
280
+ Returns:
281
+ Response confirming the project was removed
282
+ """
283
+ try:
284
+ old_project = await project_service.get_project(name)
285
+ if not old_project: # pragma: no cover
286
+ raise HTTPException(
287
+ status_code=404, detail=f"Project: '{name}' does not exist"
288
+ ) # pragma: no cover
289
+
290
+ # Check if trying to delete the default project
291
+ if name == project_service.default_project:
292
+ available_projects = await project_service.list_projects()
293
+ other_projects = [p.name for p in available_projects if p.name != name]
294
+ detail = f"Cannot delete default project '{name}'. "
295
+ if other_projects:
296
+ detail += (
297
+ f"Set another project as default first. Available: {', '.join(other_projects)}"
298
+ )
299
+ else:
300
+ detail += "This is the only project in your configuration."
301
+ raise HTTPException(status_code=400, detail=detail)
302
+
303
+ await project_service.remove_project(name, delete_notes=delete_notes)
304
+
305
+ return ProjectStatusResponse(
306
+ message=f"Project '{name}' removed successfully",
307
+ status="success",
308
+ default=False,
309
+ old_project=ProjectItem(name=old_project.name, path=old_project.path),
310
+ new_project=None,
311
+ )
312
+ except ValueError as e: # pragma: no cover
313
+ raise HTTPException(status_code=400, detail=str(e))
314
+
315
+
316
+ # Set a project as default
317
+ @project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
318
+ async def set_default_project(
319
+ project_service: ProjectServiceDep,
320
+ name: str = Path(..., description="Name of the project to set as default"),
321
+ ) -> ProjectStatusResponse:
322
+ """Set a project as the default project.
323
+
324
+ Args:
325
+ name: The name of the project to set as default
326
+
327
+ Returns:
328
+ Response confirming the project was set as default
329
+ """
330
+ try:
331
+ # Get the old default project
332
+ default_name = project_service.default_project
333
+ default_project = await project_service.get_project(default_name)
334
+ if not default_project: # pragma: no cover
335
+ raise HTTPException( # pragma: no cover
336
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
337
+ )
338
+
339
+ # get the new project
340
+ new_default_project = await project_service.get_project(name)
341
+ if not new_default_project: # pragma: no cover
342
+ raise HTTPException(
343
+ status_code=404, detail=f"Project: '{name}' does not exist"
344
+ ) # pragma: no cover
345
+
346
+ await project_service.set_default_project(name)
347
+
348
+ return ProjectStatusResponse(
349
+ message=f"Project '{name}' set as default successfully",
350
+ status="success",
351
+ default=True,
352
+ old_project=ProjectItem(name=default_name, path=default_project.path),
353
+ new_project=ProjectItem(
354
+ name=name,
355
+ path=new_default_project.path,
356
+ is_default=True,
357
+ ),
358
+ )
359
+ except ValueError as e: # pragma: no cover
360
+ raise HTTPException(status_code=400, detail=str(e))
361
+
362
+
363
+ # Get the default project
364
+ @project_resource_router.get("/default", response_model=ProjectItem)
365
+ async def get_default_project(
366
+ project_service: ProjectServiceDep,
367
+ ) -> ProjectItem:
368
+ """Get the default project.
369
+
370
+ Returns:
371
+ Response with project default information
372
+ """
373
+ # Get the old default project
374
+ default_name = project_service.default_project
375
+ default_project = await project_service.get_project(default_name)
376
+ if not default_project: # pragma: no cover
377
+ raise HTTPException( # pragma: no cover
378
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
379
+ )
380
+
381
+ return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
382
+
383
+
384
+ # Synchronize projects between config and database
385
+ @project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
386
+ async def synchronize_projects(
387
+ project_service: ProjectServiceDep,
388
+ ) -> ProjectStatusResponse:
389
+ """Synchronize projects between configuration file and database.
390
+
391
+ Ensures that all projects in the configuration file exist in the database
392
+ and vice versa.
393
+
394
+ Returns:
395
+ Response confirming synchronization was completed
396
+ """
397
+ try: # pragma: no cover
398
+ await project_service.synchronize_projects()
399
+
400
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
401
+ message="Projects synchronized successfully between configuration and database",
402
+ status="success",
403
+ default=False,
404
+ )
405
+ except ValueError as e: # pragma: no cover
406
+ raise HTTPException(status_code=400, detail=str(e))
@@ -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
+ )