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.

Files changed (47) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/routers/directory_router.py +23 -2
  3. basic_memory/api/routers/project_router.py +1 -0
  4. basic_memory/cli/auth.py +2 -2
  5. basic_memory/cli/commands/command_utils.py +11 -28
  6. basic_memory/cli/commands/mcp.py +72 -67
  7. basic_memory/cli/commands/project.py +54 -49
  8. basic_memory/cli/commands/status.py +6 -15
  9. basic_memory/config.py +55 -9
  10. basic_memory/deps.py +7 -5
  11. basic_memory/ignore_utils.py +7 -7
  12. basic_memory/mcp/async_client.py +102 -4
  13. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  14. basic_memory/mcp/prompts/search.py +12 -11
  15. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  16. basic_memory/mcp/resources/project_info.py +9 -7
  17. basic_memory/mcp/tools/build_context.py +40 -39
  18. basic_memory/mcp/tools/canvas.py +21 -20
  19. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  20. basic_memory/mcp/tools/delete_note.py +22 -21
  21. basic_memory/mcp/tools/edit_note.py +105 -104
  22. basic_memory/mcp/tools/list_directory.py +98 -95
  23. basic_memory/mcp/tools/move_note.py +127 -125
  24. basic_memory/mcp/tools/project_management.py +101 -98
  25. basic_memory/mcp/tools/read_content.py +64 -63
  26. basic_memory/mcp/tools/read_note.py +88 -88
  27. basic_memory/mcp/tools/recent_activity.py +139 -135
  28. basic_memory/mcp/tools/search.py +27 -26
  29. basic_memory/mcp/tools/sync_status.py +133 -128
  30. basic_memory/mcp/tools/utils.py +0 -15
  31. basic_memory/mcp/tools/view_note.py +14 -28
  32. basic_memory/mcp/tools/write_note.py +97 -87
  33. basic_memory/repository/entity_repository.py +60 -0
  34. basic_memory/repository/repository.py +16 -3
  35. basic_memory/repository/search_repository.py +42 -0
  36. basic_memory/schemas/project_info.py +1 -1
  37. basic_memory/services/directory_service.py +124 -3
  38. basic_memory/services/entity_service.py +31 -9
  39. basic_memory/services/project_service.py +97 -10
  40. basic_memory/services/search_service.py +16 -8
  41. basic_memory/sync/sync_service.py +28 -13
  42. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/METADATA +51 -4
  43. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/RECORD +46 -47
  44. basic_memory/mcp/tools/headers.py +0 -44
  45. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  46. {basic_memory-0.15.0.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  47. {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 client
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
- if context: # pragma: no cover
44
- await context.info("Listing all available projects")
43
+ async with get_client() as client:
44
+ if context: # pragma: no cover
45
+ await context.info("Listing all available projects")
45
46
 
46
- # Check if server is constrained to a specific project
47
- constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
47
+ # Check if server is constrained to a specific project
48
+ constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
48
49
 
49
- # Get projects from API
50
- response = await call_get(client, "/projects/projects")
51
- project_list = ProjectList.model_validate(response.json())
50
+ # Get projects from API
51
+ response = await call_get(client, "/projects/projects")
52
+ project_list = ProjectList.model_validate(response.json())
52
53
 
53
- if constrained_project:
54
- result = f"Project: {constrained_project}\n\n"
55
- result += "Note: This MCP server is constrained to a single project.\n"
56
- result += "All operations will automatically use this project."
57
- else:
58
- # Show all projects with session guidance
59
- result = "Available projects:\n"
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
- for project in project_list.projects:
62
- result += f"• {project.name}\n"
62
+ for project in project_list.projects:
63
+ result += f"• {project.name}\n"
63
64
 
64
- result += "\n" + "─" * 40 + "\n"
65
- result += "Next: Ask which project to use for this session.\n"
66
- result += "Example: 'Which project should I use for this task?'\n\n"
67
- result += "Session reminder: Track the selected project for all subsequent operations in this conversation.\n"
68
- result += "The user can say 'switch to [project]' to change projects."
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
- return result
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
- # Check if server is constrained to a specific project
95
- constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
96
- if constrained_project:
97
- 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}"`'
98
-
99
- if context: # pragma: no cover
100
- await context.info(f"Creating project: {project_name} at {project_path}")
101
-
102
- # Create the project request
103
- project_request = ProjectInfoRequest(
104
- name=project_name, path=project_path, set_default=set_default
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
- # Call API to create project
108
- response = await call_post(client, "/projects/projects", json=project_request.model_dump())
109
- status_response = ProjectStatusResponse.model_validate(response.json())
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
- result = f"✓ {status_response.message}\n\n"
113
+ result = f"✓ {status_response.message}\n\n"
112
114
 
113
- if status_response.new_project:
114
- result += "Project Details:\n"
115
- result += f"• Name: {status_response.new_project.name}\n"
116
- result += f"• Path: {status_response.new_project.path}\n"
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
- if set_default:
119
- result += "• Set as default project\n"
120
+ if set_default:
121
+ result += "• Set as default project\n"
120
122
 
121
- result += "\nProject is now available for use in tool calls.\n"
122
- result += f"Use '{project_name}' as the project parameter in MCP tool calls.\n"
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
- return result
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
- # Check if server is constrained to a specific project
149
- constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
150
- if constrained_project:
151
- 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}\"`"
152
-
153
- if context: # pragma: no cover
154
- await context.info(f"Deleting project: {project_name}")
155
-
156
- # Get project info before deletion to validate it exists
157
- response = await call_get(client, "/projects/projects")
158
- project_list = ProjectList.model_validate(response.json())
159
-
160
- # Find the project by name (case-insensitive) or permalink - same logic as switch_project
161
- project_permalink = generate_permalink(project_name)
162
- target_project = None
163
- for p in project_list.projects:
164
- # Match by permalink (handles case-insensitive input)
165
- if p.permalink == project_permalink:
166
- target_project = p
167
- break
168
- # Also match by name comparison (case-insensitive)
169
- if p.name.lower() == project_name.lower():
170
- target_project = p
171
- break
172
-
173
- if not target_project:
174
- available_projects = [p.name for p in project_list.projects]
175
- raise ValueError(
176
- f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
177
- )
178
-
179
- # Call API to delete project using URL encoding for special characters
180
- from urllib.parse import quote
181
-
182
- encoded_name = quote(target_project.name, safe="")
183
- response = await call_delete(client, f"/projects/{encoded_name}")
184
- status_response = ProjectStatusResponse.model_validate(response.json())
185
-
186
- result = f"✓ {status_response.message}\n\n"
187
-
188
- if status_response.old_project:
189
- result += "Removed project details:\n"
190
- result += f" Name: {status_response.old_project.name}\n"
191
- if hasattr(status_response.old_project, "path"):
192
- result += f"• Path: {status_response.old_project.path}\n"
193
-
194
- result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
195
- result += "Re-add the project to access its content again.\n"
196
-
197
- return result
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 client
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
- active_project = await get_active_project(client, project, context)
205
- project_url = active_project.project_url
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
- url = memory_url_path(path)
208
+ url = memory_url_path(path)
208
209
 
209
- # Validate path to prevent path traversal attacks
210
- project_path = active_project.home
211
- if not validate_project_path(url, project_path):
212
- logger.warning(
213
- "Attempted path traversal attack blocked",
214
- path=path,
215
- url=url,
216
- project=active_project.name,
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"Document size {content_length} bytes exceeds maximum allowed size",
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 client
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
- # 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,
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
- 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
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
- # 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
160
- )
161
-
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])
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: