Skip to content

Commit 14eb35e

Browse files
phernandezclaude
andcommitted
fix: recent_activity dedup + pagination across MCP tools
A user reported that recent_activity returns only 1 distinct note even though 9 were modified. Root cause: the API defaults to returning ALL types (entity, observation, relation), so a single well-connected entity fills the entire page with its observations and relations. Changes: - 🎯 Default to entity-only in recent_activity MCP tool when no type specified - 📄 Expose page/page_size params on recent_activity (were hardcoded) - ✅ Add has_more field to GraphContext and SearchResponse (N+1 trick) - 📝 Show pagination guidance ("Use page=2 to see more") in text output The API layer stays unchanged — it still accepts all types. This is purely an MCP tool UX default so LLMs see distinct notes by default. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent edb7991 commit 14eb35e

12 files changed

Lines changed: 303 additions & 22 deletions

File tree

src/basic_memory/api/v2/routers/search_router.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,28 @@ async def search(
4747
Returns:
4848
SearchResponse with paginated search results
4949
"""
50-
limit = page_size
5150
offset = (page - 1) * page_size
51+
# Fetch one extra item to detect whether more pages exist (N+1 trick)
52+
fetch_limit = page_size + 1
5253
try:
53-
results = await search_service.search(query, limit=limit, offset=offset)
54+
results = await search_service.search(query, limit=fetch_limit, offset=offset)
5455
except SemanticSearchDisabledError as exc:
5556
raise HTTPException(status_code=400, detail=str(exc)) from exc
5657
except SemanticDependenciesMissingError as exc:
5758
raise HTTPException(status_code=400, detail=str(exc)) from exc
5859
except ValueError as exc:
5960
raise HTTPException(status_code=400, detail=str(exc)) from exc
61+
62+
has_more = len(results) > page_size
63+
if has_more:
64+
results = results[:page_size]
65+
6066
search_results = await to_search_results(entity_service, results)
6167
return SearchResponse(
6268
results=search_results,
6369
current_page=page,
6470
page_size=page_size,
71+
has_more=has_more,
6572
)
6673

6774

src/basic_memory/api/v2/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def to_summary(item: SearchIndexRow | ContextResultRow):
146146
metadata=metadata,
147147
page=page,
148148
page_size=page_size,
149+
has_more=context_result.metadata.has_more,
149150
)
150151

151152

src/basic_memory/mcp/tools/project_management.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ async def list_memory_projects(
6565
result = "Available projects:\n"
6666
for project in project_list.projects:
6767
label = (
68-
f"{project.display_name} ({project.name})"
69-
if project.display_name
70-
else project.name
68+
f"{project.display_name} ({project.name})" if project.display_name else project.name
7169
)
7270
result += f"• {label}\n"
7371

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ async def recent_activity(
3939
type: Union[str, List[str]] = "",
4040
depth: int = 1,
4141
timeframe: TimeFrame = "7d",
42+
page: int = 1,
43+
page_size: int = 10,
4244
project: Optional[str] = None,
4345
workspace: Optional[str] = None,
4446
output_format: Literal["text", "json"] = "text",
@@ -70,8 +72,11 @@ async def recent_activity(
7072
- "observation" or ["observation"] for notes and observations
7173
Multiple types can be combined: ["entity", "relation"]
7274
Case-insensitive: "ENTITY" and "entity" are treated the same.
73-
Default is an empty string, which returns all types.
75+
Default is entity-only. Specify other types explicitly to include
76+
observations and relations.
7477
depth: How many relation hops to traverse (1-3 recommended)
78+
page: Page number for pagination (default 1)
79+
page_size: Number of items per page (default 10)
7580
timeframe: Time window to search. Supports natural language:
7681
- Relative: "2 days ago", "last week", "yesterday"
7782
- Points in time: "2024-01-01", "January 1st"
@@ -108,8 +113,8 @@ async def recent_activity(
108113
"""
109114
# Build common parameters for API calls
110115
params: dict = {
111-
"page": 1,
112-
"page_size": 10,
116+
"page": page,
117+
"page_size": page_size,
113118
"max_related": 10,
114119
}
115120
if depth:
@@ -139,6 +144,12 @@ async def recent_activity(
139144
# Add validated types to params
140145
params["type"] = [t.value for t in validated_types] # pyright: ignore
141146

147+
# Default to entity-only when no explicit type was provided.
148+
# This prevents a single well-connected entity from filling the page
149+
# with its observations and relations.
150+
if "type" not in params:
151+
params["type"] = [SearchItemType.ENTITY.value]
152+
142153
# Resolve project parameter using the three-tier hierarchy
143154
# allow_discovery=True enables Discovery Mode, so a project is not required
144155
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
@@ -271,7 +282,7 @@ async def recent_activity(
271282
return _extract_recent_rows(activity_data)
272283

273284
# Format project-specific mode output
274-
return _format_project_output(resolved_project, activity_data, timeframe, type)
285+
return _format_project_output(resolved_project, activity_data, timeframe, type, page)
275286

276287

277288
async def _get_project_activity(
@@ -424,6 +435,7 @@ def _format_project_output(
424435
activity_data: GraphContext,
425436
timeframe: str,
426437
type_filter: Union[str, List[str]],
438+
page: int = 1,
427439
) -> str:
428440
"""Format project-specific mode output as human-readable text."""
429441
lines = [f"## Recent Activity: {project_name} ({timeframe})"]
@@ -504,12 +516,15 @@ def _format_project_output(
504516

505517
lines.append(f" • {from_link}{rel_type}{to_link}")
506518

507-
# Activity summary
519+
# Activity summary with pagination guidance
508520
total = len(activity_data.results)
509-
lines.append(f"\n**Activity Summary:** {total} items found")
510-
if hasattr(activity_data, "metadata") and activity_data.metadata:
511-
if hasattr(activity_data.metadata, "total_results"):
512-
lines.append(f"Total available: {activity_data.metadata.total_results}")
521+
if activity_data.has_more:
522+
lines.append(
523+
f"\n**Activity Summary:** Showing {total} items (page {page}). "
524+
f"Use page={page + 1} to see more."
525+
)
526+
else:
527+
lines.append(f"\n**Activity Summary:** {total} items found.")
513528

514529
return "\n".join(lines)
515530

src/basic_memory/schemas/memory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ class GraphContext(BaseModel):
238238

239239
page: Optional[int] = None
240240
page_size: Optional[int] = None
241+
has_more: bool = False
241242

242243

243244
class ActivityStats(BaseModel):

src/basic_memory/schemas/search.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,4 @@ class SearchResponse(BaseModel):
141141
results: List[SearchResult]
142142
current_page: int
143143
page_size: int
144+
has_more: bool = False

src/basic_memory/services/context_service.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class ContextMetadata:
5757
related_count: int = 0
5858
total_observations: int = 0
5959
total_relations: int = 0
60+
has_more: bool = False
6061

6162

6263
@dataclass
@@ -102,6 +103,9 @@ async def build_context(
102103
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
103104
)
104105

106+
# Fetch one extra item to detect whether more pages exist (N+1 trick)
107+
fetch_limit = limit + 1
108+
105109
normalized_path: Optional[str] = None
106110
if memory_url:
107111
path = memory_url_path(memory_url)
@@ -118,21 +122,26 @@ async def build_context(
118122
normalized_path = "*".join(normalized_parts)
119123
logger.debug(f"Pattern search for '{normalized_path}'")
120124
primary = await self.search_repository.search(
121-
permalink_match=normalized_path, limit=limit, offset=offset
125+
permalink_match=normalized_path, limit=fetch_limit, offset=offset
122126
)
123127
else:
124128
# For exact paths, normalize the whole thing
125129
normalized_path = generate_permalink(path, split_extension=False)
126130
logger.debug(f"Direct lookup for '{normalized_path}'")
127131
primary = await self.search_repository.search(
128-
permalink=normalized_path, limit=limit, offset=offset
132+
permalink=normalized_path, limit=fetch_limit, offset=offset
129133
)
130134
else:
131135
logger.debug(f"Build context for '{types}'")
132136
primary = await self.search_repository.search(
133-
search_item_types=types, after_date=since, limit=limit, offset=offset
137+
search_item_types=types, after_date=since, limit=fetch_limit, offset=offset
134138
)
135139

140+
# Trim to requested limit and set has_more flag
141+
has_more = len(primary) > limit
142+
if has_more:
143+
primary = primary[:limit]
144+
136145
# Get type_id pairs for traversal
137146

138147
type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
@@ -171,6 +180,7 @@ async def build_context(
171180
related_count=len(related),
172181
total_observations=sum(len(obs) for obs in observations_by_entity.values()),
173182
total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
183+
has_more=has_more,
174184
)
175185

176186
# Build context results list directly with ContextResultItem objects

src/basic_memory/services/project_service.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ class ProjectService:
3939

4040
repository: ProjectRepository
4141

42-
def __init__(
43-
self, repository: ProjectRepository, file_service: Optional["FileService"] = None
44-
):
42+
def __init__(self, repository: ProjectRepository, file_service: Optional["FileService"] = None):
4543
"""Initialize the project service."""
4644
super().__init__()
4745
self.repository = repository

tests/api/v2/test_memory_router.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,41 @@ async def test_get_memory_context_with_timeframe(
288288
assert "results" in data
289289

290290

291+
@pytest.mark.asyncio
292+
async def test_recent_context_has_more(
293+
client: AsyncClient,
294+
test_project: Project,
295+
v2_project_url: str,
296+
entity_repository,
297+
search_service,
298+
file_service,
299+
):
300+
"""has_more should be True when there are more results beyond the current page."""
301+
# Create enough entities to exceed a small page_size
302+
for i in range(4):
303+
entity_data = {
304+
"title": f"HasMore Memory {i}",
305+
"entity_type": "note",
306+
"content_type": "text/markdown",
307+
"file_path": f"hasmore_mem_{i}.md",
308+
"checksum": f"hasmoremem{i}",
309+
}
310+
await create_test_entity(
311+
test_project, entity_data, entity_repository, search_service, file_service
312+
)
313+
314+
# Request page_size=2 — with 4 entities, has_more should be True
315+
response = await client.get(
316+
f"{v2_project_url}/memory/recent",
317+
params={"page": 1, "page_size": 2, "type": ["entity"]},
318+
)
319+
assert response.status_code == 200
320+
data = response.json()
321+
assert "has_more" in data
322+
assert data["has_more"] is True
323+
assert len(data["results"]) == 2
324+
325+
291326
@pytest.mark.asyncio
292327
async def test_v2_memory_endpoints_use_project_id_not_name(
293328
client: AsyncClient,

tests/api/v2/test_search_router.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,70 @@ async def search(self, *args, **kwargs):
361361

362362
assert response.status_code == 400
363363
assert "Vector retrieval requires a text query" in response.json()["detail"]
364+
365+
366+
@pytest.mark.asyncio
367+
async def test_search_has_more_when_more_results_exist(
368+
client: AsyncClient,
369+
test_project: Project,
370+
v2_project_url: str,
371+
entity_repository,
372+
search_service,
373+
file_service,
374+
):
375+
"""has_more should be True when there are more results beyond the current page."""
376+
# Create enough entities to exceed a small page_size
377+
for i in range(4):
378+
entity_data = {
379+
"title": f"HasMore Entity {i}",
380+
"entity_type": "note",
381+
"content_type": "text/markdown",
382+
"file_path": f"hasmore_{i}.md",
383+
"checksum": f"hasmore{i}",
384+
}
385+
await create_test_entity(
386+
test_project, entity_data, entity_repository, search_service, file_service
387+
)
388+
389+
# Request page_size=2 — with 4 entities, has_more should be True
390+
response = await client.post(
391+
f"{v2_project_url}/search/",
392+
json={"text": "HasMore Entity"},
393+
params={"page": 1, "page_size": 2},
394+
)
395+
assert response.status_code == 200
396+
data = response.json()
397+
assert "has_more" in data
398+
assert data["has_more"] is True
399+
assert len(data["results"]) == 2
400+
401+
402+
@pytest.mark.asyncio
403+
async def test_search_has_more_false_on_last_page(
404+
client: AsyncClient,
405+
test_project: Project,
406+
v2_project_url: str,
407+
entity_repository,
408+
search_service,
409+
file_service,
410+
):
411+
"""has_more should be False when all results fit on the current page."""
412+
entity_data = {
413+
"title": "Solo Search Entity",
414+
"entity_type": "note",
415+
"content_type": "text/markdown",
416+
"file_path": "solo_search.md",
417+
"checksum": "solo123",
418+
}
419+
await create_test_entity(
420+
test_project, entity_data, entity_repository, search_service, file_service
421+
)
422+
423+
response = await client.post(
424+
f"{v2_project_url}/search/",
425+
json={"text": "Solo Search Entity"},
426+
params={"page": 1, "page_size": 10},
427+
)
428+
assert response.status_code == 200
429+
data = response.json()
430+
assert data["has_more"] is False

0 commit comments

Comments
 (0)