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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- 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"}
|