Skip to content

Commit ee03975

Browse files
phernandezclaude
andauthored
fix: recent_activity dedup + pagination across MCP tools (#595)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9c9ff29 commit ee03975

13 files changed

Lines changed: 653 additions & 54 deletions

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: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Recent activity tool for Basic Memory MCP server."""
22

33
from datetime import timezone
4+
from pathlib import PurePosixPath
45
from typing import List, Union, Optional, Literal
56

67
from loguru import logger
@@ -39,6 +40,8 @@ async def recent_activity(
3940
type: Union[str, List[str]] = "",
4041
depth: int = 1,
4142
timeframe: TimeFrame = "7d",
43+
page: int = 1,
44+
page_size: int = 10,
4245
project: Optional[str] = None,
4346
workspace: Optional[str] = None,
4447
output_format: Literal["text", "json"] = "text",
@@ -70,8 +73,11 @@ async def recent_activity(
7073
- "observation" or ["observation"] for notes and observations
7174
Multiple types can be combined: ["entity", "relation"]
7275
Case-insensitive: "ENTITY" and "entity" are treated the same.
73-
Default is an empty string, which returns all types.
76+
Default is entity-only. Specify other types explicitly to include
77+
observations and relations.
7478
depth: How many relation hops to traverse (1-3 recommended)
79+
page: Page number for pagination (default 1)
80+
page_size: Number of items per page (default 10)
7581
timeframe: Time window to search. Supports natural language:
7682
- Relative: "2 days ago", "last week", "yesterday"
7783
- Points in time: "2024-01-01", "January 1st"
@@ -106,10 +112,19 @@ async def recent_activity(
106112
- For focused queries, consider using build_context with a specific URI
107113
- Max timeframe is 1 year in the past
108114
"""
115+
# Validate pagination arguments before they reach the API layer,
116+
# where negative offset would cause a database error.
117+
if page < 1:
118+
raise ValueError(f"page must be >= 1, got {page}")
119+
if page_size < 1:
120+
raise ValueError(f"page_size must be >= 1, got {page_size}")
121+
if page_size > 100:
122+
raise ValueError(f"page_size must be <= 100, got {page_size}")
123+
109124
# Build common parameters for API calls
110125
params: dict = {
111-
"page": 1,
112-
"page_size": 10,
126+
"page": page,
127+
"page_size": page_size,
113128
"max_related": 10,
114129
}
115130
if depth:
@@ -139,6 +154,12 @@ async def recent_activity(
139154
# Add validated types to params
140155
params["type"] = [t.value for t in validated_types] # pyright: ignore
141156

157+
# Default to entity-only when no explicit type was provided.
158+
# This prevents a single well-connected entity from filling the page
159+
# with its observations and relations.
160+
if "type" not in params:
161+
params["type"] = [SearchItemType.ENTITY.value]
162+
142163
# Resolve project parameter using the three-tier hierarchy
143164
# allow_discovery=True enables Discovery Mode, so a project is not required
144165
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
@@ -271,7 +292,7 @@ async def recent_activity(
271292
return _extract_recent_rows(activity_data)
272293

273294
# Format project-specific mode output
274-
return _format_project_output(resolved_project, activity_data, timeframe, type)
295+
return _format_project_output(resolved_project, activity_data, timeframe, type, page)
275296

276297

277298
async def _get_project_activity(
@@ -312,9 +333,9 @@ async def _get_project_activity(
312333
last_activity = current_time
313334

314335
# Extract folder from file_path
315-
if hasattr(result.primary_result, "file_path") and result.primary_result.file_path:
316-
folder = "/".join(result.primary_result.file_path.split("/")[:-1])
317-
if folder:
336+
if result.primary_result.file_path:
337+
folder = str(PurePosixPath(result.primary_result.file_path).parent)
338+
if folder and folder != ".":
318339
active_folders.add(folder)
319340

320341
return ProjectActivity(
@@ -339,9 +360,7 @@ def _extract_recent_rows(
339360
"title": primary.title,
340361
"permalink": primary.permalink,
341362
"file_path": primary.file_path,
342-
"created_at": (
343-
primary.created_at.isoformat() if getattr(primary, "created_at", None) else None
344-
),
363+
"created_at": primary.created_at.isoformat() if primary.created_at else None,
345364
}
346365
if project_name is not None:
347366
row["project"] = project_name
@@ -365,7 +384,7 @@ def _format_discovery_output(
365384
# Get latest activity from most active project
366385
if most_active.activity.results:
367386
latest = most_active.activity.results[0].primary_result
368-
title = latest.title if hasattr(latest, "title") and latest.title else "Recent activity"
387+
title = latest.title or "Recent activity"
369388
# Format relative time
370389
time_str = (
371390
_format_relative_time(latest.created_at) if latest.created_at else "unknown time"
@@ -394,9 +413,7 @@ def _format_discovery_output(
394413
for name, activity in projects_activity.items():
395414
if activity.item_count > 0:
396415
for result in activity.activity.results[:3]: # Top 3 from each active project
397-
if result.primary_result.type == "entity" and hasattr(
398-
result.primary_result, "title"
399-
):
416+
if result.primary_result.type == "entity":
400417
title = result.primary_result.title
401418
# Look for status indicators in titles
402419
if any(word in title.lower() for word in ["complete", "fix", "test", "spec"]):
@@ -424,6 +441,7 @@ def _format_project_output(
424441
activity_data: GraphContext,
425442
timeframe: str,
426443
type_filter: Union[str, List[str]],
444+
page: int = 1,
427445
) -> str:
428446
"""Format project-specific mode output as human-readable text."""
429447
lines = [f"## Recent Activity: {project_name} ({timeframe})"]
@@ -449,12 +467,12 @@ def _format_project_output(
449467
if entities:
450468
lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
451469
for entity in entities[:5]: # Show top 5
452-
title = entity.title if hasattr(entity, "title") and entity.title else "Untitled"
453-
# Get folder from file_path if available
470+
title = entity.title or "Untitled"
471+
# Get folder from file_path
454472
folder = ""
455-
if hasattr(entity, "file_path") and entity.file_path:
456-
folder_path = "/".join(entity.file_path.split("/")[:-1])
457-
if folder_path:
473+
if entity.file_path:
474+
folder_path = str(PurePosixPath(entity.file_path).parent)
475+
if folder_path and folder_path != ".":
458476
folder = f" ({folder_path})"
459477
lines.append(f" • {title}{folder}")
460478

@@ -464,21 +482,15 @@ def _format_project_output(
464482
# Group by category
465483
by_category = {}
466484
for obs in observations[:10]: # Limit to recent ones
467-
category = (
468-
getattr(obs, "category", "general") if hasattr(obs, "category") else "general"
469-
)
485+
category = obs.category
470486
if category not in by_category:
471487
by_category[category] = []
472488
by_category[category].append(obs)
473489

474490
for category, obs_list in list(by_category.items())[:5]: # Show top 5 categories
475491
lines.append(f" **{category}:** {len(obs_list)} items")
476492
for obs in obs_list[:2]: # Show 2 examples per category
477-
content = (
478-
getattr(obs, "content", "No content")
479-
if hasattr(obs, "content")
480-
else "No content"
481-
)
493+
content = obs.content
482494
# Truncate at word boundary
483495
if len(content) > 80:
484496
content = _truncate_at_word(content, 80)
@@ -488,28 +500,25 @@ def _format_project_output(
488500
if relations:
489501
lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
490502
for rel in relations[:5]: # Show top 5
491-
rel_type = (
492-
getattr(rel, "relation_type", "relates_to")
493-
if hasattr(rel, "relation_type")
494-
else "relates_to"
495-
)
496-
from_entity = (
497-
getattr(rel, "from_entity", "Unknown") if hasattr(rel, "from_entity") else "Unknown"
498-
)
499-
to_entity = getattr(rel, "to_entity", None) if hasattr(rel, "to_entity") else None
503+
rel_type = rel.relation_type
504+
from_entity = rel.from_entity or "Unknown"
505+
to_entity = rel.to_entity
500506

501507
# Format as WikiLinks to show they're readable notes
502508
from_link = f"[[{from_entity}]]" if from_entity != "Unknown" else from_entity
503509
to_link = f"[[{to_entity}]]" if to_entity else "[Missing Link]"
504510

505511
lines.append(f" • {from_link}{rel_type}{to_link}")
506512

507-
# Activity summary
513+
# Activity summary with pagination guidance
508514
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}")
515+
if activity_data.has_more:
516+
lines.append(
517+
f"\n**Activity Summary:** Showing {total} items (page {page}). "
518+
f"Use page={page + 1} to see more."
519+
)
520+
else:
521+
lines.append(f"\n**Activity Summary:** {total} items found.")
513522

514523
return "\n".join(lines)
515524

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

0 commit comments

Comments
 (0)