Skip to content

Commit ef18845

Browse files
groksrcclaude
andcommitted
fix(mcp): cap recent_activity rows with explicit truncation footer
The project-mode formatter previously hardcoded a display cap of 5 entities and 5 relations, but the heading and the "Activity Summary" line both reported the true count returned by the API. The result was a misleading silent truncation: e.g. a heading of "Recent Notes & Documents (9)" with only 5 rows below it and no signal that 4 had been dropped. This raises the cap to 10 (matching the default page_size) and, when the cap is hit, appends an explicit "…and N more on this page (raise page_size to display all)" line so the truncation is visible to the caller. Below the cap, behavior is unchanged. Adds two regression tests covering both the below-cap (no footer) and above-cap (footer present, body truncated to cap) paths. Closes #784 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 5ccf433 commit ef18845

2 files changed

Lines changed: 179 additions & 10 deletions

File tree

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
from basic_memory.schemas.search import SearchItemType
2626

2727

28+
# Display cap for project-mode entity and relation rows in the formatted output.
29+
# The API has already paginated to `page_size`; this is a separate readability
30+
# guardrail that keeps the LLM-facing text compact when callers ask for large
31+
# pages. When exceeded, `_format_project_output` appends an explicit
32+
# "…and N more" line so the truncation is visible rather than silent.
33+
_PROJECT_OUTPUT_DISPLAY_CAP = 10
34+
35+
2836
@mcp.tool(
2937
description="""Get recent activity for a project or across all projects.
3038
@@ -187,9 +195,7 @@ async def recent_activity(
187195
# project_id (UUID) takes precedence over project name — without this fallback,
188196
# callers passing only project_id would fall into Discovery Mode.
189197
effective_identifier = project_id if project_id else project
190-
resolved_project = await resolve_project_parameter(
191-
effective_identifier, allow_discovery=True
192-
)
198+
resolved_project = await resolve_project_parameter(effective_identifier, allow_discovery=True)
193199

194200
if resolved_project is None:
195201
# Discovery Mode: Get activity across all projects
@@ -304,9 +310,7 @@ async def recent_activity(
304310
f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
305311
)
306312

307-
async with get_project_client(
308-
resolved_project, context=context, project_id=project_id
309-
) as (
313+
async with get_project_client(resolved_project, context=context, project_id=project_id) as (
310314
client,
311315
active_project,
312316
):
@@ -492,10 +496,16 @@ def _format_project_output(
492496
elif result.primary_result.type == "observation":
493497
observations.append(result.primary_result)
494498

495-
# Show entities (notes/documents)
499+
# Show entities (notes/documents).
500+
# Trigger: more entities returned than the display cap.
501+
# Why: keep formatted output compact for LLM context while still surfacing the
502+
# true total in the heading. Previously the cap was silent — the heading said
503+
# N items and the body listed only 5, with no signal the body was truncated.
504+
# Outcome: render up to `_PROJECT_OUTPUT_DISPLAY_CAP` rows; if any were dropped,
505+
# append an explicit "…and N more" line directing the caller to raise page_size.
496506
if entities:
497507
lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
498-
for entity in entities[:5]: # Show top 5
508+
for entity in entities[:_PROJECT_OUTPUT_DISPLAY_CAP]:
499509
title = entity.title or "Untitled"
500510
# Get folder from file_path
501511
folder = ""
@@ -504,6 +514,9 @@ def _format_project_output(
504514
if folder_path and folder_path != ".":
505515
folder = f" ({folder_path})"
506516
lines.append(f" • {title}{folder}")
517+
if len(entities) > _PROJECT_OUTPUT_DISPLAY_CAP:
518+
hidden = len(entities) - _PROJECT_OUTPUT_DISPLAY_CAP
519+
lines.append(f" …and {hidden} more on this page (raise page_size to display all)")
507520

508521
# Show observations (categorized insights)
509522
if observations:
@@ -525,10 +538,10 @@ def _format_project_output(
525538
content = _truncate_at_word(content, 80)
526539
lines.append(f" - {content}")
527540

528-
# Show relations (connections)
541+
# Show relations (connections). Same cap-with-explicit-truncation pattern as entities.
529542
if relations:
530543
lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
531-
for rel in relations[:5]: # Show top 5
544+
for rel in relations[:_PROJECT_OUTPUT_DISPLAY_CAP]:
532545
rel_type = rel.relation_type
533546
from_entity = rel.from_entity or "Unknown"
534547
to_entity = rel.to_entity
@@ -538,6 +551,9 @@ def _format_project_output(
538551
to_link = f"[[{to_entity}]]" if to_entity else "[Missing Link]"
539552

540553
lines.append(f" • {from_link}{rel_type}{to_link}")
554+
if len(relations) > _PROJECT_OUTPUT_DISPLAY_CAP:
555+
hidden = len(relations) - _PROJECT_OUTPUT_DISPLAY_CAP
556+
lines.append(f" …and {hidden} more on this page (raise page_size to display all)")
541557

542558
# Activity summary with pagination guidance
543559
total = len(activity_data.results)

tests/mcp/test_tool_recent_activity.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,159 @@ def test_recent_activity_format_project_output_no_results():
259259
assert "No recent activity found" in out
260260

261261

262+
def test_recent_activity_format_project_output_renders_all_entities_and_relations():
263+
"""Regression for #784, part 1: when the result count is below the display cap
264+
the formatter must render every row. Previously the cap was hardcoded at 5,
265+
so a result set of 7 entities would show 5 rows under a heading that claimed 7.
266+
"""
267+
import importlib
268+
269+
from basic_memory.schemas.memory import RelationSummary
270+
271+
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
272+
now = datetime.now(timezone.utc)
273+
274+
entity_titles = [f"Entity {i}" for i in range(7)]
275+
relation_titles = [f"Relation {i}" for i in range(6)]
276+
277+
results = [
278+
ContextResult(
279+
primary_result=EntitySummary(
280+
external_id=f"550e8400-e29b-41d4-a716-44665544{i:04d}",
281+
entity_id=i,
282+
permalink=f"notes/entity-{i}",
283+
title=title,
284+
content=None,
285+
file_path=f"notes/entity-{i}.md",
286+
created_at=now,
287+
),
288+
observations=[],
289+
related_results=[],
290+
)
291+
for i, title in enumerate(entity_titles)
292+
] + [
293+
ContextResult(
294+
primary_result=RelationSummary(
295+
relation_id=100 + i,
296+
entity_id=i,
297+
title=title,
298+
file_path=f"notes/entity-{i}.md",
299+
permalink=f"notes/entity-{i}",
300+
relation_type="references",
301+
from_entity=f"Entity {i}",
302+
to_entity=f"Entity {i + 1}",
303+
created_at=now,
304+
),
305+
observations=[],
306+
related_results=[],
307+
)
308+
for i, title in enumerate(relation_titles)
309+
]
310+
311+
activity = GraphContext(
312+
results=results,
313+
metadata=MemoryMetadata(depth=1, generated_at=now),
314+
)
315+
316+
out = recent_activity_module._format_project_output(
317+
project_name="proj",
318+
activity_data=activity,
319+
timeframe="7d",
320+
type_filter=["entity", "relation"],
321+
page=1,
322+
)
323+
324+
for title in entity_titles:
325+
assert title in out, f"Entity {title!r} missing from formatter output"
326+
for i in range(len(relation_titles)):
327+
assert f"[[Entity {i}]] → references → [[Entity {i + 1}]]" in out, (
328+
f"Relation {i} missing from formatter output"
329+
)
330+
# Below-cap path: no truncation footer should appear.
331+
assert "more on this page" not in out
332+
333+
334+
def test_recent_activity_format_project_output_caps_with_explicit_truncation():
335+
"""Regression for #784, part 2: above the display cap the formatter must
336+
truncate to the cap AND emit an explicit "…and N more" footer so the caller
337+
can see that the body is a preview of a longer page (and knows to raise
338+
page_size).
339+
"""
340+
import importlib
341+
342+
from basic_memory.schemas.memory import RelationSummary
343+
344+
recent_activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity")
345+
cap = recent_activity_module._PROJECT_OUTPUT_DISPLAY_CAP
346+
now = datetime.now(timezone.utc)
347+
348+
entity_count = cap + 2
349+
relation_count = cap + 3
350+
351+
# Use prefixes that don't collide between entities and relation endpoints,
352+
# so substring assertions don't accidentally match across rows.
353+
results = [
354+
ContextResult(
355+
primary_result=EntitySummary(
356+
external_id=f"550e8400-e29b-41d4-a716-44665544{i:04d}",
357+
entity_id=i,
358+
permalink=f"notes/entity-{i}",
359+
title=f"NoteFile {i}",
360+
content=None,
361+
file_path=f"notes/entity-{i}.md",
362+
created_at=now,
363+
),
364+
observations=[],
365+
related_results=[],
366+
)
367+
for i in range(entity_count)
368+
] + [
369+
ContextResult(
370+
primary_result=RelationSummary(
371+
relation_id=1000 + i,
372+
entity_id=i,
373+
title=f"Relation {i}",
374+
file_path=f"notes/entity-{i}.md",
375+
permalink=f"notes/entity-{i}",
376+
relation_type="references",
377+
from_entity=f"FromNode {i}",
378+
to_entity=f"ToNode {i}",
379+
created_at=now,
380+
),
381+
observations=[],
382+
related_results=[],
383+
)
384+
for i in range(relation_count)
385+
]
386+
387+
activity = GraphContext(
388+
results=results,
389+
metadata=MemoryMetadata(depth=1, generated_at=now),
390+
)
391+
392+
out = recent_activity_module._format_project_output(
393+
project_name="proj",
394+
activity_data=activity,
395+
timeframe="7d",
396+
type_filter=["entity", "relation"],
397+
page=1,
398+
)
399+
400+
# Heading reports the true total (independent of truncation).
401+
assert f"Recent Notes & Documents ({entity_count})" in out
402+
assert f"Recent Connections ({relation_count})" in out
403+
404+
# Body shows exactly `cap` rows of each.
405+
assert f"NoteFile {cap - 1}" in out, "expected last in-cap entity to appear"
406+
assert f"NoteFile {cap}" not in out, "entity beyond cap should be truncated"
407+
assert f"[[FromNode {cap - 1}]] → references → [[ToNode {cap - 1}]]" in out
408+
assert f"[[FromNode {cap}]]" not in out, "relation beyond cap should be truncated"
409+
410+
# Truncation footer is present and reports the correct hidden count.
411+
assert f"…and {entity_count - cap} more on this page" in out
412+
assert f"…and {relation_count - cap} more on this page" in out
413+
414+
262415
def test_recent_activity_format_project_output_includes_observation_truncation():
263416
import importlib
264417

0 commit comments

Comments
 (0)