basic-memory 0.15.0__py3-none-any.whl → 0.15.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.
- basic_memory/__init__.py +1 -1
- basic_memory/api/routers/directory_router.py +23 -2
- basic_memory/api/routers/project_router.py +1 -0
- basic_memory/cli/auth.py +2 -2
- basic_memory/cli/commands/command_utils.py +11 -28
- basic_memory/cli/commands/mcp.py +72 -67
- basic_memory/cli/commands/project.py +54 -49
- basic_memory/cli/commands/status.py +6 -15
- basic_memory/config.py +55 -9
- basic_memory/deps.py +7 -5
- basic_memory/ignore_utils.py +7 -7
- basic_memory/mcp/async_client.py +102 -4
- basic_memory/mcp/prompts/continue_conversation.py +16 -15
- basic_memory/mcp/prompts/search.py +12 -11
- basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
- basic_memory/mcp/resources/project_info.py +9 -7
- basic_memory/mcp/tools/build_context.py +40 -39
- basic_memory/mcp/tools/canvas.py +21 -20
- basic_memory/mcp/tools/chatgpt_tools.py +11 -2
- basic_memory/mcp/tools/delete_note.py +22 -21
- basic_memory/mcp/tools/edit_note.py +105 -104
- basic_memory/mcp/tools/list_directory.py +98 -95
- basic_memory/mcp/tools/move_note.py +127 -125
- basic_memory/mcp/tools/project_management.py +101 -98
- basic_memory/mcp/tools/read_content.py +64 -63
- basic_memory/mcp/tools/read_note.py +88 -88
- basic_memory/mcp/tools/recent_activity.py +139 -135
- basic_memory/mcp/tools/search.py +27 -26
- basic_memory/mcp/tools/sync_status.py +133 -128
- basic_memory/mcp/tools/utils.py +0 -15
- basic_memory/mcp/tools/view_note.py +14 -28
- basic_memory/mcp/tools/write_note.py +97 -87
- basic_memory/repository/entity_repository.py +60 -0
- basic_memory/repository/repository.py +16 -3
- basic_memory/repository/search_repository.py +42 -0
- basic_memory/schemas/project_info.py +1 -1
- basic_memory/services/directory_service.py +124 -3
- basic_memory/services/entity_service.py +31 -9
- basic_memory/services/project_service.py +97 -10
- basic_memory/services/search_service.py +16 -8
- basic_memory/sync/sync_service.py +28 -13
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
- basic_memory/mcp/tools/headers.py +0 -44
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,7 +7,7 @@ and manage project context during conversations.
|
|
|
7
7
|
import os
|
|
8
8
|
from fastmcp import Context
|
|
9
9
|
|
|
10
|
-
from basic_memory.mcp.async_client import
|
|
10
|
+
from basic_memory.mcp.async_client import get_client
|
|
11
11
|
from basic_memory.mcp.server import mcp
|
|
12
12
|
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
|
|
13
13
|
from basic_memory.schemas.project_info import (
|
|
@@ -40,34 +40,35 @@ async def list_memory_projects(context: Context | None = None) -> str:
|
|
|
40
40
|
Example:
|
|
41
41
|
list_memory_projects()
|
|
42
42
|
"""
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
async with get_client() as client:
|
|
44
|
+
if context: # pragma: no cover
|
|
45
|
+
await context.info("Listing all available projects")
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
# Check if server is constrained to a specific project
|
|
48
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
# Get projects from API
|
|
51
|
+
response = await call_get(client, "/projects/projects")
|
|
52
|
+
project_list = ProjectList.model_validate(response.json())
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
if constrained_project:
|
|
55
|
+
result = f"Project: {constrained_project}\n\n"
|
|
56
|
+
result += "Note: This MCP server is constrained to a single project.\n"
|
|
57
|
+
result += "All operations will automatically use this project."
|
|
58
|
+
else:
|
|
59
|
+
# Show all projects with session guidance
|
|
60
|
+
result = "Available projects:\n"
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
for project in project_list.projects:
|
|
63
|
+
result += f"• {project.name}\n"
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
result += "\n" + "─" * 40 + "\n"
|
|
66
|
+
result += "Next: Ask which project to use for this session.\n"
|
|
67
|
+
result += "Example: 'Which project should I use for this task?'\n\n"
|
|
68
|
+
result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
|
|
69
|
+
result += "The user can say 'switch to [project]' to change projects."
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
return result
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
@mcp.tool("create_memory_project")
|
|
@@ -91,37 +92,38 @@ async def create_memory_project(
|
|
|
91
92
|
create_memory_project("my-research", "~/Documents/research")
|
|
92
93
|
create_memory_project("work-notes", "/home/user/work", set_default=True)
|
|
93
94
|
"""
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
async with get_client() as client:
|
|
96
|
+
# Check if server is constrained to a specific project
|
|
97
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
98
|
+
if constrained_project:
|
|
99
|
+
return f'# Error\n\nProject creation disabled - MCP server is constrained to project \'{constrained_project}\'.\nUse the CLI to create projects: `basic-memory project add "{project_name}" "{project_path}"`'
|
|
100
|
+
|
|
101
|
+
if context: # pragma: no cover
|
|
102
|
+
await context.info(f"Creating project: {project_name} at {project_path}")
|
|
103
|
+
|
|
104
|
+
# Create the project request
|
|
105
|
+
project_request = ProjectInfoRequest(
|
|
106
|
+
name=project_name, path=project_path, set_default=set_default
|
|
107
|
+
)
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
# Call API to create project
|
|
110
|
+
response = await call_post(client, "/projects/projects", json=project_request.model_dump())
|
|
111
|
+
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
result = f"✓ {status_response.message}\n\n"
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
if status_response.new_project:
|
|
116
|
+
result += "Project Details:\n"
|
|
117
|
+
result += f"• Name: {status_response.new_project.name}\n"
|
|
118
|
+
result += f"• Path: {status_response.new_project.path}\n"
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
if set_default:
|
|
121
|
+
result += "• Set as default project\n"
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
result += "\nProject is now available for use in tool calls.\n"
|
|
124
|
+
result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
return result
|
|
125
127
|
|
|
126
128
|
|
|
127
129
|
@mcp.tool()
|
|
@@ -145,53 +147,54 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
|
|
|
145
147
|
This action cannot be undone. The project will need to be re-added
|
|
146
148
|
to access its content through Basic Memory again.
|
|
147
149
|
"""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
150
|
+
async with get_client() as client:
|
|
151
|
+
# Check if server is constrained to a specific project
|
|
152
|
+
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
|
|
153
|
+
if constrained_project:
|
|
154
|
+
return f"# Error\n\nProject deletion disabled - MCP server is constrained to project '{constrained_project}'.\nUse the CLI to delete projects: `basic-memory project remove \"{project_name}\"`"
|
|
155
|
+
|
|
156
|
+
if context: # pragma: no cover
|
|
157
|
+
await context.info(f"Deleting project: {project_name}")
|
|
158
|
+
|
|
159
|
+
# Get project info before deletion to validate it exists
|
|
160
|
+
response = await call_get(client, "/projects/projects")
|
|
161
|
+
project_list = ProjectList.model_validate(response.json())
|
|
162
|
+
|
|
163
|
+
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
|
|
164
|
+
project_permalink = generate_permalink(project_name)
|
|
165
|
+
target_project = None
|
|
166
|
+
for p in project_list.projects:
|
|
167
|
+
# Match by permalink (handles case-insensitive input)
|
|
168
|
+
if p.permalink == project_permalink:
|
|
169
|
+
target_project = p
|
|
170
|
+
break
|
|
171
|
+
# Also match by name comparison (case-insensitive)
|
|
172
|
+
if p.name.lower() == project_name.lower():
|
|
173
|
+
target_project = p
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if not target_project:
|
|
177
|
+
available_projects = [p.name for p in project_list.projects]
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Call API to delete project using URL encoding for special characters
|
|
183
|
+
from urllib.parse import quote
|
|
184
|
+
|
|
185
|
+
encoded_name = quote(target_project.name, safe="")
|
|
186
|
+
response = await call_delete(client, f"/projects/{encoded_name}")
|
|
187
|
+
status_response = ProjectStatusResponse.model_validate(response.json())
|
|
188
|
+
|
|
189
|
+
result = f"✓ {status_response.message}\n\n"
|
|
190
|
+
|
|
191
|
+
if status_response.old_project:
|
|
192
|
+
result += "Removed project details:\n"
|
|
193
|
+
result += f"• Name: {status_response.old_project.name}\n"
|
|
194
|
+
if hasattr(status_response.old_project, "path"):
|
|
195
|
+
result += f"• Path: {status_response.old_project.path}\n"
|
|
196
|
+
|
|
197
|
+
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
|
|
198
|
+
result += "Re-add the project to access its content again.\n"
|
|
199
|
+
|
|
200
|
+
return result
|
|
@@ -16,7 +16,7 @@ from fastmcp import Context
|
|
|
16
16
|
|
|
17
17
|
from basic_memory.mcp.project_context import get_active_project
|
|
18
18
|
from basic_memory.mcp.server import mcp
|
|
19
|
-
from basic_memory.mcp.async_client import
|
|
19
|
+
from basic_memory.mcp.async_client import get_client
|
|
20
20
|
from basic_memory.mcp.tools.utils import call_get
|
|
21
21
|
from basic_memory.schemas.memory import memory_url_path
|
|
22
22
|
from basic_memory.utils import validate_project_path
|
|
@@ -201,70 +201,71 @@ async def read_content(
|
|
|
201
201
|
"""
|
|
202
202
|
logger.info("Reading file", path=path, project=project)
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
async with get_client() as client:
|
|
205
|
+
active_project = await get_active_project(client, project, context)
|
|
206
|
+
project_url = active_project.project_url
|
|
206
207
|
|
|
207
|
-
|
|
208
|
+
url = memory_url_path(path)
|
|
208
209
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
"type": "error",
|
|
220
|
-
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
224
|
-
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
225
|
-
content_length = int(response.headers.get("content-length", 0))
|
|
226
|
-
|
|
227
|
-
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
|
|
228
|
-
|
|
229
|
-
# Handle text or json
|
|
230
|
-
if content_type.startswith("text/") or content_type == "application/json":
|
|
231
|
-
logger.debug("Processing text resource")
|
|
232
|
-
return {
|
|
233
|
-
"type": "text",
|
|
234
|
-
"text": response.text,
|
|
235
|
-
"content_type": content_type,
|
|
236
|
-
"encoding": "utf-8",
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
# Handle images
|
|
240
|
-
elif content_type.startswith("image/"):
|
|
241
|
-
logger.debug("Processing image")
|
|
242
|
-
img = PILImage.open(io.BytesIO(response.content))
|
|
243
|
-
img_bytes = optimize_image(img, content_length)
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
"type": "image",
|
|
247
|
-
"source": {
|
|
248
|
-
"type": "base64",
|
|
249
|
-
"media_type": "image/jpeg",
|
|
250
|
-
"data": base64.b64encode(img_bytes).decode("utf-8"),
|
|
251
|
-
},
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
# Handle other file types
|
|
255
|
-
else:
|
|
256
|
-
logger.debug(f"Processing binary resource content_type {content_type}")
|
|
257
|
-
if content_length > 350000: # pragma: no cover
|
|
258
|
-
logger.warning("Document too large for response", size=content_length)
|
|
210
|
+
# Validate path to prevent path traversal attacks
|
|
211
|
+
project_path = active_project.home
|
|
212
|
+
if not validate_project_path(url, project_path):
|
|
213
|
+
logger.warning(
|
|
214
|
+
"Attempted path traversal attack blocked",
|
|
215
|
+
path=path,
|
|
216
|
+
url=url,
|
|
217
|
+
project=active_project.name,
|
|
218
|
+
)
|
|
259
219
|
return {
|
|
260
220
|
"type": "error",
|
|
261
|
-
"error": f"
|
|
221
|
+
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
response = await call_get(client, f"{project_url}/resource/{url}")
|
|
225
|
+
content_type = response.headers.get("content-type", "application/octet-stream")
|
|
226
|
+
content_length = int(response.headers.get("content-length", 0))
|
|
227
|
+
|
|
228
|
+
logger.debug("Resource metadata", content_type=content_type, size=content_length, path=path)
|
|
229
|
+
|
|
230
|
+
# Handle text or json
|
|
231
|
+
if content_type.startswith("text/") or content_type == "application/json":
|
|
232
|
+
logger.debug("Processing text resource")
|
|
233
|
+
return {
|
|
234
|
+
"type": "text",
|
|
235
|
+
"text": response.text,
|
|
236
|
+
"content_type": content_type,
|
|
237
|
+
"encoding": "utf-8",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# Handle images
|
|
241
|
+
elif content_type.startswith("image/"):
|
|
242
|
+
logger.debug("Processing image")
|
|
243
|
+
img = PILImage.open(io.BytesIO(response.content))
|
|
244
|
+
img_bytes = optimize_image(img, content_length)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"type": "image",
|
|
248
|
+
"source": {
|
|
249
|
+
"type": "base64",
|
|
250
|
+
"media_type": "image/jpeg",
|
|
251
|
+
"data": base64.b64encode(img_bytes).decode("utf-8"),
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Handle other file types
|
|
256
|
+
else:
|
|
257
|
+
logger.debug(f"Processing binary resource content_type {content_type}")
|
|
258
|
+
if content_length > 350000: # pragma: no cover
|
|
259
|
+
logger.warning("Document too large for response", size=content_length)
|
|
260
|
+
return {
|
|
261
|
+
"type": "error",
|
|
262
|
+
"error": f"Document size {content_length} bytes exceeds maximum allowed size",
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
"type": "document",
|
|
266
|
+
"source": {
|
|
267
|
+
"type": "base64",
|
|
268
|
+
"media_type": content_type,
|
|
269
|
+
"data": base64.b64encode(response.content).decode("utf-8"),
|
|
270
|
+
},
|
|
262
271
|
}
|
|
263
|
-
return {
|
|
264
|
-
"type": "document",
|
|
265
|
-
"source": {
|
|
266
|
-
"type": "base64",
|
|
267
|
-
"media_type": content_type,
|
|
268
|
-
"data": base64.b64encode(response.content).decode("utf-8"),
|
|
269
|
-
},
|
|
270
|
-
}
|
|
@@ -6,7 +6,7 @@ from typing import Optional
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
from fastmcp import Context
|
|
8
8
|
|
|
9
|
-
from basic_memory.mcp.async_client import
|
|
9
|
+
from basic_memory.mcp.async_client import get_client
|
|
10
10
|
from basic_memory.mcp.project_context import get_active_project
|
|
11
11
|
from basic_memory.mcp.server import mcp
|
|
12
12
|
from basic_memory.mcp.tools.search import search_notes
|
|
@@ -77,96 +77,96 @@ async def read_note(
|
|
|
77
77
|
If the exact note isn't found, this tool provides helpful suggestions
|
|
78
78
|
including related notes, search commands, and note creation templates.
|
|
79
79
|
"""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
80
|
+
async with get_client() as client:
|
|
81
|
+
# Get and validate the project
|
|
82
|
+
active_project = await get_active_project(client, project, context)
|
|
83
|
+
|
|
84
|
+
# Validate identifier to prevent path traversal attacks
|
|
85
|
+
# We need to check both the raw identifier and the processed path
|
|
86
|
+
processed_path = memory_url_path(identifier)
|
|
87
|
+
project_path = active_project.home
|
|
88
|
+
|
|
89
|
+
if not validate_project_path(identifier, project_path) or not validate_project_path(
|
|
90
|
+
processed_path, project_path
|
|
91
|
+
):
|
|
92
|
+
logger.warning(
|
|
93
|
+
"Attempted path traversal attack blocked",
|
|
94
|
+
identifier=identifier,
|
|
95
|
+
processed_path=processed_path,
|
|
96
|
+
project=active_project.name,
|
|
97
|
+
)
|
|
98
|
+
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
|
|
99
|
+
|
|
100
|
+
# Check migration status and wait briefly if needed
|
|
101
|
+
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
102
|
+
|
|
103
|
+
migration_status = await wait_for_migration_or_return_status(
|
|
104
|
+
timeout=5.0, project_name=active_project.name
|
|
105
|
+
)
|
|
106
|
+
if migration_status: # pragma: no cover
|
|
107
|
+
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
|
|
108
|
+
project_url = active_project.project_url
|
|
109
|
+
|
|
110
|
+
# Get the file via REST API - first try direct permalink lookup
|
|
111
|
+
entity_path = memory_url_path(identifier)
|
|
112
|
+
path = f"{project_url}/resource/{entity_path}"
|
|
113
|
+
logger.info(f"Attempting to read note from Project: {active_project.name} URL: {path}")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Try direct lookup first
|
|
117
|
+
response = await call_get(client, path, params={"page": page, "page_size": page_size})
|
|
118
|
+
|
|
119
|
+
# If successful, return the content
|
|
120
|
+
if response.status_code == 200:
|
|
121
|
+
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
|
122
|
+
return response.text
|
|
123
|
+
except Exception as e: # pragma: no cover
|
|
124
|
+
logger.info(f"Direct lookup failed for '{path}': {e}")
|
|
125
|
+
# Continue to fallback methods
|
|
126
|
+
|
|
127
|
+
# Fallback 1: Try title search via API
|
|
128
|
+
logger.info(f"Search title for: {identifier}")
|
|
129
|
+
title_results = await search_notes.fn(
|
|
130
|
+
query=identifier, search_type="title", project=project, context=context
|
|
97
131
|
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# Fallback 1: Try title search via API
|
|
128
|
-
logger.info(f"Search title for: {identifier}")
|
|
129
|
-
title_results = await search_notes.fn(
|
|
130
|
-
query=identifier, search_type="title", project=project, context=context
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
# Handle both SearchResponse object and error strings
|
|
134
|
-
if title_results and hasattr(title_results, "results") and title_results.results:
|
|
135
|
-
result = title_results.results[0] # Get the first/best match
|
|
136
|
-
if result.permalink:
|
|
137
|
-
try:
|
|
138
|
-
# Try to fetch the content using the found permalink
|
|
139
|
-
path = f"{project_url}/resource/{result.permalink}"
|
|
140
|
-
response = await call_get(
|
|
141
|
-
client, path, params={"page": page, "page_size": page_size}
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
if response.status_code == 200:
|
|
145
|
-
logger.info(f"Found note by title search: {result.permalink}")
|
|
146
|
-
return response.text
|
|
147
|
-
except Exception as e: # pragma: no cover
|
|
148
|
-
logger.info(
|
|
149
|
-
f"Failed to fetch content for found title match {result.permalink}: {e}"
|
|
150
|
-
)
|
|
151
|
-
else:
|
|
152
|
-
logger.info(
|
|
153
|
-
f"No results in title search for: {identifier} in project {active_project.name}"
|
|
132
|
+
|
|
133
|
+
# Handle both SearchResponse object and error strings
|
|
134
|
+
if title_results and hasattr(title_results, "results") and title_results.results:
|
|
135
|
+
result = title_results.results[0] # Get the first/best match
|
|
136
|
+
if result.permalink:
|
|
137
|
+
try:
|
|
138
|
+
# Try to fetch the content using the found permalink
|
|
139
|
+
path = f"{project_url}/resource/{result.permalink}"
|
|
140
|
+
response = await call_get(
|
|
141
|
+
client, path, params={"page": page, "page_size": page_size}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if response.status_code == 200:
|
|
145
|
+
logger.info(f"Found note by title search: {result.permalink}")
|
|
146
|
+
return response.text
|
|
147
|
+
except Exception as e: # pragma: no cover
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Failed to fetch content for found title match {result.permalink}: {e}"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
logger.info(
|
|
153
|
+
f"No results in title search for: {identifier} in project {active_project.name}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Fallback 2: Text search as a last resort
|
|
157
|
+
logger.info(f"Title search failed, trying text search for: {identifier}")
|
|
158
|
+
text_results = await search_notes.fn(
|
|
159
|
+
query=identifier, search_type="text", project=project, context=context
|
|
154
160
|
)
|
|
155
161
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if not text_results or not hasattr(text_results, "results") or not text_results.results:
|
|
165
|
-
# No results at all
|
|
166
|
-
return format_not_found_message(active_project.name, identifier)
|
|
167
|
-
else:
|
|
168
|
-
# We found some related results
|
|
169
|
-
return format_related_results(active_project.name, identifier, text_results.results[:5])
|
|
162
|
+
# We didn't find a direct match, construct a helpful error message
|
|
163
|
+
# Handle both SearchResponse object and error strings
|
|
164
|
+
if not text_results or not hasattr(text_results, "results") or not text_results.results:
|
|
165
|
+
# No results at all
|
|
166
|
+
return format_not_found_message(active_project.name, identifier)
|
|
167
|
+
else:
|
|
168
|
+
# We found some related results
|
|
169
|
+
return format_related_results(active_project.name, identifier, text_results.results[:5])
|
|
170
170
|
|
|
171
171
|
|
|
172
172
|
def format_not_found_message(project: str | None, identifier: str) -> str:
|