Skip to content

Commit 2c5b63c

Browse files
phernandezclaude
andcommitted
fix: default search_notes to entity-level results (#31)
search_notes was returning individual observations and relations as separate top-level results, wasting the result limit and creating confusing UX. Default entity_types to ["entity"] when the caller doesn't specify it — the entity row already indexes full file content, so no matches are lost. Users can still override with explicit entity_types=["observation"] etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent c4c9f84 commit 2c5b63c

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,13 @@ async def search_notes(
518518
"`tags`, `status`, `note_types`, `entity_types`, or `after_date`."
519519
)
520520

521+
# Default to entity-level results to avoid returning individual
522+
# observations/relations as separate search results (see issue #31).
523+
# Applied after no_criteria() so that the implicit default doesn't
524+
# mask a truly empty search request.
525+
if not search_query.entity_types:
526+
search_query.entity_types = [SearchItemType("entity")]
527+
521528
logger.debug(f"Searching for {search_query} in project {active_project.name}")
522529
# Import here to avoid circular import (tools → clients → utils → tools)
523530
from basic_memory.mcp.clients import SearchClient

tests/mcp/test_tool_search.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,3 +1023,99 @@ def raise_runtime_error():
10231023

10241024
assert captured_payload["retrieval_mode"] == "fts"
10251025
assert captured_payload["text"] == "test query"
1026+
1027+
1028+
# --- Tests for default entity_types (issue #31) --------------------------------
1029+
1030+
1031+
@pytest.mark.asyncio
1032+
async def test_search_notes_defaults_entity_types_to_entity(monkeypatch):
1033+
"""search_notes defaults entity_types to ['entity'] when not explicitly provided.
1034+
1035+
This prevents individual observations/relations from appearing as separate
1036+
search results, since the entity row already indexes full file content.
1037+
"""
1038+
import importlib
1039+
1040+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1041+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1042+
1043+
class StubProject:
1044+
name = "test-project"
1045+
external_id = "test-external-id"
1046+
1047+
@asynccontextmanager
1048+
async def fake_get_project_client(*args, **kwargs):
1049+
yield (object(), StubProject())
1050+
1051+
async def fake_resolve_project_and_path(
1052+
client, identifier, project=None, context=None, headers=None
1053+
):
1054+
return StubProject(), identifier, False
1055+
1056+
captured_payload: dict = {}
1057+
1058+
class MockSearchClient:
1059+
def __init__(self, *args, **kwargs):
1060+
pass
1061+
1062+
async def search(self, payload, page, page_size):
1063+
captured_payload.update(payload)
1064+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1065+
1066+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1067+
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
1068+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1069+
1070+
await search_mod.search_notes(
1071+
project="test-project",
1072+
query="test",
1073+
)
1074+
1075+
# entity_types should default to ["entity"]
1076+
assert captured_payload["entity_types"] == ["entity"]
1077+
1078+
1079+
@pytest.mark.asyncio
1080+
async def test_search_notes_explicit_entity_types_overrides_default(monkeypatch):
1081+
"""Explicit entity_types parameter overrides the default ['entity'] filter."""
1082+
import importlib
1083+
1084+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1085+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1086+
1087+
class StubProject:
1088+
name = "test-project"
1089+
external_id = "test-external-id"
1090+
1091+
@asynccontextmanager
1092+
async def fake_get_project_client(*args, **kwargs):
1093+
yield (object(), StubProject())
1094+
1095+
async def fake_resolve_project_and_path(
1096+
client, identifier, project=None, context=None, headers=None
1097+
):
1098+
return StubProject(), identifier, False
1099+
1100+
captured_payload: dict = {}
1101+
1102+
class MockSearchClient:
1103+
def __init__(self, *args, **kwargs):
1104+
pass
1105+
1106+
async def search(self, payload, page, page_size):
1107+
captured_payload.update(payload)
1108+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1109+
1110+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1111+
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
1112+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1113+
1114+
await search_mod.search_notes(
1115+
project="test-project",
1116+
query="test",
1117+
entity_types=["observation"],
1118+
)
1119+
1120+
# Explicit entity_types should be used, not the default
1121+
assert captured_payload["entity_types"] == ["observation"]

0 commit comments

Comments
 (0)