-
Notifications
You must be signed in to change notification settings - Fork 188
Expand file tree
/
Copy pathsearch.py
More file actions
646 lines (542 loc) · 28.5 KB
/
search.py
File metadata and controls
646 lines (542 loc) · 28.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
"""Search tools for Basic Memory MCP server."""
import re
from textwrap import dedent
from typing import Annotated, List, Optional, Dict, Any, Literal
from loguru import logger
from fastmcp import Context
from pydantic import BeforeValidator
from basic_memory.config import ConfigManager
from basic_memory.utils import coerce_dict, coerce_list
from basic_memory.mcp.container import get_container
from basic_memory.mcp.project_context import (
detect_project_from_url_prefix,
get_project_client,
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import (
SearchItemType,
SearchQuery,
SearchResponse,
SearchRetrievalMode,
)
def _default_search_type() -> str:
"""Pick default search mode from config, falling back to auto-detection.
Priority: config default_search_type > auto-detect (hybrid if semantic enabled, else text).
"""
try:
config = get_container().config
except RuntimeError:
config = ConfigManager().config
if config.default_search_type:
return config.default_search_type
return "hybrid" if config.semantic_search_enabled else "text"
def _format_search_error_response(
project: str, error_message: str, query: str, search_type: str = "text"
) -> str:
"""Format helpful error responses for search failures that guide users to successful searches."""
# Semantic config/dependency errors
if "semantic search is disabled" in error_message.lower():
return dedent(f"""
# Search Failed - Semantic Search Disabled
You requested `{search_type}` search for query '{query}', but semantic search is disabled.
## How to enable
1. Set `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true`
2. Restart the Basic Memory server/process
## Alternative now
- Run FTS search instead:
`search_notes("{project}", "{query}", search_type="text")`
""").strip()
if "pip install" in error_message.lower() and "semantic" in error_message.lower():
return dedent(f"""
# Search Failed - Semantic Dependencies Missing
Semantic retrieval is enabled but required packages are not installed.
## Fix
1. Install/update Basic Memory: `pip install -U basic-memory`
2. Restart Basic Memory
3. Retry your query:
`search_notes("{project}", "{query}", search_type="{search_type}")`
""").strip()
# FTS5 syntax errors
if "syntax error" in error_message.lower() or "fts5" in error_message.lower():
clean_query = (
query.replace('"', "")
.replace("(", "")
.replace(")", "")
.replace("+", "")
.replace("*", "")
)
return dedent(f"""
# Search Failed - Invalid Syntax
The search query '{query}' contains invalid syntax that the search engine cannot process.
## Common syntax issues:
1. **Special characters**: Characters like `+`, `*`, `"`, `(`, `)` have special meaning in search
2. **Unmatched quotes**: Make sure quotes are properly paired
3. **Invalid operators**: Check AND, OR, NOT operators are used correctly
## How to fix:
1. **Simplify your search**: Try using simple words instead: `{clean_query}`
2. **Remove special characters**: Use alphanumeric characters and spaces
3. **Use basic boolean operators**: `word1 AND word2`, `word1 OR word2`, `word1 NOT word2`
## Examples of valid searches:
- Simple text: `project planning`
- Boolean AND: `project AND planning`
- Boolean OR: `meeting OR discussion`
- Boolean NOT: `project NOT archived`
- Grouped: `(project OR planning) AND notes`
- Exact phrases: `"weekly standup meeting"`
- Content-specific: `tag:example` or `category:observation`
## Try again with:
```
search_notes("{project}","{clean_query}")
```
## Alternative search strategies:
- Break into simpler terms: `search_notes("{project}", "{" ".join(clean_query.split()[:2])}")`
- Try different search types: `search_notes("{project}","{clean_query}", search_type="title")`
- Use filtering: `search_notes("{project}","{clean_query}", note_types=["note"])`
""").strip()
# Project not found errors (check before general "not found")
if "project not found" in error_message.lower():
return dedent(f"""
# Search Failed - Project Not Found
The current project is not accessible or doesn't exist: {error_message}
## How to resolve:
1. **Check available projects**: `list_projects()`
3. **Verify project setup**: Ensure your project is properly configured
## Current session info:
- See available projects: `list_projects()`
""").strip()
# No results found
if "no results" in error_message.lower() or "not found" in error_message.lower():
simplified_query = (
" ".join(query.split()[:2])
if len(query.split()) > 2
else query.split()[0]
if query.split()
else "notes"
)
return dedent(f"""
# Search Complete - No Results Found
No content found matching '{query}' in the current project.
## Search strategy suggestions:
1. **Broaden your search**: Try fewer or more general terms
- Instead of: `{query}`
- Try: `{simplified_query}`
2. **Check spelling and try variations**:
- Verify terms are spelled correctly
- Try synonyms or related terms
3. **Use different search approaches**:
- **Text search**: `search_notes("{project}","{query}", search_type="text")` (searches full content)
- **Title search**: `search_notes("{project}","{query}", search_type="title")` (searches only titles)
- **Permalink search**: `search_notes("{project}","{query}", search_type="permalink")` (searches file paths)
4. **Try boolean operators for broader results**:
- OR search: `search_notes("{project}","{" OR ".join(query.split()[:3])}")`
- Remove restrictive terms: Focus on the most important keywords
5. **Use filtering to narrow scope**:
- By note type in frontmatter: `search_notes("{project}","{query}", note_types=["note"])`
- By recent content: `search_notes("{project}","{query}", after_date="1 week")`
- By entity type: `search_notes("{project}","{query}", entity_types=["observation"])`
6. **Try advanced search patterns**:
- Tag search: `search_notes("{project}","tag:your-tag")`
- Category search: `search_notes("{project}","category:observation")`
- Pattern matching: `search_notes("{project}","*{query}*", search_type="permalink")`
## Explore what content exists:
- **Recent activity**: `recent_activity(timeframe="7d")` - See what's been updated recently
- **List directories**: `list_directory("{project}","/")` - Browse all content
- **Browse by folder**: `list_directory("{project}","/notes")` or `list_directory("/docs")`
""").strip()
# Server/API errors
if "server error" in error_message.lower() or "internal" in error_message.lower():
return dedent(f"""
# Search Failed - Server Error
The search service encountered an error while processing '{query}': {error_message}
## Immediate steps:
1. **Try again**: The error might be temporary
2. **Simplify the query**: Use simpler search terms
3. **Check project status**: Ensure your project is properly synced
## Alternative approaches:
- Browse files directly: `list_directory("{project}","/")`
- Check recent activity: `recent_activity(timeframe="7d")`
- Try a different search type: `search_notes("{project}","{query}", search_type="title")`
## If the problem persists:
The search index might need to be rebuilt. Send a message to support@basicmachines.co or check the project sync status.
""").strip()
# Permission/access errors
if (
"permission" in error_message.lower()
or "access" in error_message.lower()
or "forbidden" in error_message.lower()
):
return f"""# Search Failed - Access Error
You don't have permission to search in the current project: {error_message}
## How to resolve:
1. **Check your project access**: Verify you have read permissions for this project
2. **Switch projects**: Try searching in a different project you have access to
3. **Check authentication**: You might need to re-authenticate
## Alternative actions:
- List available projects: `list_projects()`"""
# Generic fallback
return f"""# Search Failed
Error searching for '{query}': {error_message}
## Troubleshooting steps:
1. **Simplify your query**: Try basic words without special characters
2. **Check search syntax**: Ensure boolean operators are correctly formatted
3. **Verify project access**: Make sure you can access the current project
4. **Test with simple search**: Try `search_notes("test")` to verify search is working
## Alternative search approaches:
- **Different search types**:
- Title only: `search_notes("{project}","{query}", search_type="title")`
- Permalink patterns: `search_notes("{project}","{query}*", search_type="permalink")`
- **With filters**: `search_notes("{project}","{query}", note_types=["note"])`
- **Recent content**: `search_notes("{project}","{query}", after_date="1 week")`
- **Boolean variations**: `search_notes("{project}","{" OR ".join(query.split()[:2])}")`
## Explore your content:
- **Browse files**: `list_directory("{project}","/")` - See all available content
- **Recent activity**: `recent_activity(timeframe="7d")` - Check what's been updated
- **All projects**: `list_projects()`
## Search syntax reference:
- **Basic**: `keyword` or `multiple words`
- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2`
- **Phrases**: `"exact phrase"`
- **Grouping**: `(term1 OR term2) AND term3`
- **Patterns**: `tag:example`, `category:observation`"""
def _format_search_markdown(result: SearchResponse, project: str, query: str | None) -> str:
"""Format SearchResponse as compact markdown text.
Produces a human-readable markdown representation suitable for LLM
consumption when structured data isn't needed.
"""
if not result.results:
return f"No results found for '{query or ''}' in project '{project}'."
parts = []
# --- Header ---
if query:
parts.append(f"# Search Results: {query}")
else:
parts.append("# Search Results")
parts.append(f"*project: {project}*")
parts.append("")
# --- Result blocks ---
for r in result.results:
parts.append(f"### {r.title}")
parts.append(f"- permalink: {r.permalink}")
parts.append(f"- score: {r.score:.4f}")
if r.matched_chunk:
parts.append(f"- match: {r.matched_chunk[:200]}")
parts.append("")
# --- Footer with pagination ---
parts.append("---")
count = len(result.results)
parts.append(
f"*{count} result{'s' if count != 1 else ''}"
f" | page {result.current_page}, page_size {result.page_size}"
f"{' | more available' if result.has_more else ''}*"
)
return "\n".join(parts)
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_notes(
query: Optional[str] = None,
project: Optional[str] = None,
workspace: Optional[str] = None,
page: int = 1,
page_size: int = 10,
search_type: str | None = None,
output_format: Literal["text", "json"] = "text",
note_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
"Case-insensitive.",
] = None,
entity_types: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
"'Chapter' here — use note_types instead.",
] = None,
after_date: Optional[str] = None,
metadata_filters: Annotated[
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
] = None,
tags: Annotated[
List[str] | None,
BeforeValidator(coerce_list),
] = None,
status: Optional[str] = None,
min_similarity: Optional[float] = None,
context: Context | None = None,
) -> dict | str:
"""Search across all content in the knowledge base with comprehensive syntax support.
This tool searches the knowledge base using full-text search, pattern matching,
or exact permalink lookup. It supports filtering by content type, entity type,
and date, with advanced boolean and phrase search capabilities.
Project Resolution:
Server resolves projects in this order: Single Project Mode → project parameter → default project.
If project unknown, use list_memory_projects() or recent_activity() first.
## Search Syntax Examples
### Basic Searches
- `search_notes("my-project", "keyword")` - Find any content containing "keyword"
- `search_notes("work-docs", "'exact phrase'")` - Search for exact phrase match
### Advanced Boolean Searches
- `search_notes("my-project", "term1 term2")` - Strict implicit-AND first; retries with
relaxed OR terms only if strict search returns no results
- `search_notes("my-project", "term1 AND term2")` - Explicit AND search (both terms required)
- `search_notes("my-project", "term1 OR term2")` - Either term can be present
- `search_notes("my-project", "term1 NOT term2")` - Include term1 but exclude term2
- `search_notes("my-project", "(project OR planning) AND notes")` - Grouped boolean logic
### Content-Specific Searches
- `search_notes("research", "tag:example")` - Search within specific tags (if supported by content)
- `search_notes("work-project", "category:observation")` - Filter by observation categories
- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
with any search type (text, hybrid, vector). You can also use the `tags` parameter
directly: `search_notes("project", "query", tags=["my-tag"])`
### Search Type Examples
- `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles
- `search_notes("work-docs", "docs/meeting-*", search_type="permalink")` - Pattern match permalinks
Note: Permalink patterns match the full path (e.g., "project/folder/chapter-13*", not just "chapter-13*").
- `search_notes("research", "keyword")` - Default search (hybrid when semantic is enabled,
text when disabled)
### Filtering Options
- `search_notes("my-project", "query", note_types=["note"])` - Search only notes
- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
- `search_notes("my-project", "query", status="in-progress")` - Filter by frontmatter status
- `search_notes("my-project", "query", metadata_filters={"priority": {"$in": ["high"]}})`
### Structured Metadata Filters
Filters are exact matches on frontmatter metadata. Supported forms:
- Equality: `{"status": "in-progress"}`
- Array contains (all): `{"tags": ["security", "oauth"]}`
- Operators:
- `$in`: `{"priority": {"$in": ["high", "critical"]}}`
- `$gt`, `$gte`, `$lt`, `$lte`: `{"schema.confidence": {"$gt": 0.7}}`
- `$between`: `{"schema.confidence": {"$between": [0.3, 0.6]}}`
- Nested keys use dot notation (e.g., `"schema.confidence"`).
### Filter-only Searches
Omit `query` (or pass None) when only using structured filters:
- `search_notes(metadata_filters={"type": "spec"}, project="my-project")`
- `search_notes(tags=["security"], project="my-project")`
- `search_notes(status="draft", project="my-project")`
### Convenience Filters
`tags` and `status` are shorthand for metadata_filters. If the same key exists in
metadata_filters, that value wins.
### Advanced Pattern Examples
- `search_notes("work-project", "project AND (meeting OR discussion)")` - Complex boolean logic
- `search_notes("research", "\"exact phrase\" AND keyword")` - Combine phrase and keyword search
- `search_notes("dev-notes", "bug NOT fixed")` - Exclude resolved issues
- `search_notes("archive", "docs/2024-*", search_type="permalink")` - Year-based permalink search
Args:
query: Optional search query string (supports boolean operators, phrases, patterns).
Omit or pass None for filter-only searches using metadata_filters, tags, or status.
project: Project name to search in. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
page: The page number of results to return (default 1)
page_size: The number of results to return per page (default 10)
search_type: Type of search to perform, one of:
"text", "title", "permalink", "vector", "semantic", "hybrid".
Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text".
output_format: "text" preserves existing structured search response behavior.
"json" returns a machine-readable dictionary payload.
note_types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
min_similarity: Optional float to override the global semantic_min_similarity threshold
for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision.
Only applies to vector and hybrid search types.
context: Optional FastMCP context for performance caching.
Returns:
Formatted markdown text (output_format="text"), dict (output_format="json"),
or helpful error guidance string if search fails
Examples:
# Basic text search
results = await search_notes("project planning")
# Plain multi-term text uses strict matching first, then relaxed OR fallback if needed
# Boolean AND search (both terms must be present)
results = await search_notes("project AND planning")
# Boolean OR search (either term can be present)
results = await search_notes("project OR meeting")
# Boolean NOT search (exclude terms)
results = await search_notes("project NOT meeting")
# Boolean search with grouping
results = await search_notes("(project OR planning) AND notes")
# Exact phrase search
results = await search_notes("\"weekly standup meeting\"")
# Search with note type filter - type property in frontmatter
results = await search_notes(
"meeting notes",
note_types=["note"],
)
# Search with entity type filter
results = await search_notes(
"meeting notes",
entity_types=["observation"],
)
# Search for recent content
results = await search_notes(
"bug report",
after_date="1 week"
)
# Pattern matching on permalinks
results = await search_notes(
"docs/meeting-*",
search_type="permalink"
)
# Title-only search
results = await search_notes(
"Machine Learning",
search_type="title"
)
# Complex search with multiple filters
results = await search_notes(
"(bug OR issue) AND NOT resolved",
note_types=["note"],
after_date="2024-01-01"
)
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
entity_types = entity_types or []
# Parse tag:<value> shorthand at tool level so it works with all search modes.
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
# Without this, hybrid/vector modes fail because they require non-empty text,
# but the service-layer tag: parser clears the text after the mode is set.
if query and "tag:" in query.lower():
# Extract tag values, splitting comma-separated lists (e.g. "tag:coffee,brewing")
raw_values = re.findall(r"tag:(\S+)", query, flags=re.IGNORECASE)
tag_values = [v for raw in raw_values for v in raw.split(",") if v]
if tag_values:
# Merge with any explicitly provided tags
tags = list(set((tags or []) + tag_values))
# Remove tag: tokens and boolean connectors, keep remaining text as query
remainder = re.sub(r"tag:\S+", "", query, flags=re.IGNORECASE)
remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip()
query = remainder or None
# Detect project from memory URL prefix before routing
if project is None and query is not None:
detected = detect_project_from_url_prefix(query, ConfigManager().config)
if detected:
project = detected
async with get_project_client(project, workspace, context) as (client, active_project):
# Handle memory:// URLs by resolving to permalink search
is_memory_url = False
if query is not None:
_, resolved_query, is_memory_url = await resolve_project_and_path(
client, query, project, context
)
if is_memory_url:
query = resolved_query
effective_search_type = search_type or _default_search_type()
if is_memory_url:
effective_search_type = "permalink"
try:
# Create a SearchQuery object based on the parameters
search_query = SearchQuery()
# Only map search_type to query fields when there is an actual query string.
# When query is None/empty, skip the search mode block — filters-only path.
effective_query = (query or "").strip()
if effective_query:
valid_search_types = {
"text",
"title",
"permalink",
"vector",
"semantic",
"hybrid",
}
if effective_search_type == "text":
search_query.text = effective_query
search_query.retrieval_mode = SearchRetrievalMode.FTS
elif effective_search_type in ("vector", "semantic"):
search_query.text = effective_query
search_query.retrieval_mode = SearchRetrievalMode.VECTOR
elif effective_search_type == "hybrid":
search_query.text = effective_query
search_query.retrieval_mode = SearchRetrievalMode.HYBRID
elif effective_search_type == "title":
search_query.title = effective_query
elif effective_search_type == "permalink" and "*" in effective_query:
search_query.permalink_match = effective_query
elif effective_search_type == "permalink":
search_query.permalink = effective_query
else:
raise ValueError(
f"Invalid search_type '{effective_search_type}'. "
f"Valid options: {', '.join(sorted(valid_search_types))}"
)
# Add optional filters if provided (empty lists are treated as no filter)
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if note_types:
search_query.note_types = note_types
if after_date:
search_query.after_date = after_date
if metadata_filters:
# Alias common column/model names to their frontmatter key equivalents.
# Users often pass "note_type" (the entity model column) when the
# frontmatter field is actually "type".
_METADATA_KEY_ALIASES = {"note_type": "type"}
metadata_filters = {
_METADATA_KEY_ALIASES.get(k, k): v for k, v in metadata_filters.items()
}
search_query.metadata_filters = metadata_filters
if tags:
search_query.tags = tags
if status:
search_query.status = status
if min_similarity is not None:
search_query.min_similarity = min_similarity
# Reject searches with no criteria at all
if search_query.no_criteria():
return (
"# No Search Criteria\n\n"
"Please provide at least one of: `query`, `metadata_filters`, "
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
)
# Default to entity-level results to avoid returning individual
# observations/relations as separate search results (see issue #31).
# Applied after no_criteria() so that the implicit default doesn't
# mask a truly empty search request.
if not search_query.entity_types:
search_query.entity_types = [SearchItemType("entity")]
logger.debug(f"Searching for {search_query} in project {active_project.name}")
# Import here to avoid circular import (tools → clients → utils → tools)
from basic_memory.mcp.clients import SearchClient
# Use typed SearchClient for API calls
search_client = SearchClient(client, active_project.external_id)
result = await search_client.search(
search_query.model_dump(),
page=page,
page_size=page_size,
)
# Check if we got no results and provide helpful guidance
if not result.results:
logger.debug(
f"Search returned no results for query: {query} in project {active_project.name}"
)
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
if output_format == "json":
return result.model_dump(mode="json", exclude_none=True)
return _format_search_markdown(result, active_project.name, query)
except Exception as e:
logger.error(
f"Search failed for query '{query or ''}': {e}, project: {active_project.name}"
)
# Return formatted error message as string for better user experience
return _format_search_error_response(
active_project.name, str(e), query or "", effective_search_type
)