Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/basic_memory/api/v2/routers/search_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,28 @@ async def search(
Returns:
SearchResponse with paginated search results
"""
limit = page_size
offset = (page - 1) * page_size
# Fetch one extra item to detect whether more pages exist (N+1 trick)
fetch_limit = page_size + 1
try:
results = await search_service.search(query, limit=limit, offset=offset)
results = await search_service.search(query, limit=fetch_limit, offset=offset)
except SemanticSearchDisabledError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SemanticDependenciesMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

has_more = len(results) > page_size
if has_more:
results = results[:page_size]

search_results = await to_search_results(entity_service, results)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
has_more=has_more,
)


Expand Down
1 change: 1 addition & 0 deletions src/basic_memory/api/v2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def to_summary(item: SearchIndexRow | ContextResultRow):
metadata=metadata,
page=page,
page_size=page_size,
has_more=context_result.metadata.has_more,
)


Expand Down
4 changes: 1 addition & 3 deletions src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@ async def list_memory_projects(
result = "Available projects:\n"
for project in project_list.projects:
label = (
f"{project.display_name} ({project.name})"
if project.display_name
else project.name
f"{project.display_name} ({project.name})" if project.display_name else project.name
)
result += f"• {label}\n"

Expand Down
91 changes: 50 additions & 41 deletions src/basic_memory/mcp/tools/recent_activity.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Recent activity tool for Basic Memory MCP server."""

from datetime import timezone
from pathlib import PurePosixPath
from typing import List, Union, Optional, Literal

from loguru import logger
Expand Down Expand Up @@ -39,6 +40,8 @@ async def recent_activity(
type: Union[str, List[str]] = "",
depth: int = 1,
timeframe: TimeFrame = "7d",
page: int = 1,
page_size: int = 10,
project: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
Expand Down Expand Up @@ -70,8 +73,11 @@ async def recent_activity(
- "observation" or ["observation"] for notes and observations
Multiple types can be combined: ["entity", "relation"]
Case-insensitive: "ENTITY" and "entity" are treated the same.
Default is an empty string, which returns all types.
Default is entity-only. Specify other types explicitly to include
observations and relations.
depth: How many relation hops to traverse (1-3 recommended)
page: Page number for pagination (default 1)
page_size: Number of items per page (default 10)
timeframe: Time window to search. Supports natural language:
- Relative: "2 days ago", "last week", "yesterday"
- Points in time: "2024-01-01", "January 1st"
Expand Down Expand Up @@ -106,10 +112,19 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
# Validate pagination arguments before they reach the API layer,
# where negative offset would cause a database error.
if page < 1:
raise ValueError(f"page must be >= 1, got {page}")
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
if page_size > 100:
raise ValueError(f"page_size must be <= 100, got {page_size}")

# Build common parameters for API calls
params: dict = {
"page": 1,
"page_size": 10,
"page": page,
"page_size": page_size,
Comment on lines +126 to +127

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate pagination arguments before calling recent API

The new page/page_size inputs are forwarded as-is, but this tool does not enforce page >= 1 and page_size >= 1. In project mode these values are passed to /v2/projects/{id}/memory/recent, where offset is derived from page and can become negative; Postgres rejects negative OFFSET, so values like page=0 now surface as server/tool errors instead of a clear validation error. This regression appears only after exposing pagination params here, since the previous implementation always used safe constants.

Useful? React with 👍 / 👎.

"max_related": 10,
}
if depth:
Expand Down Expand Up @@ -139,6 +154,12 @@ async def recent_activity(
# Add validated types to params
params["type"] = [t.value for t in validated_types] # pyright: ignore

# Default to entity-only when no explicit type was provided.
# This prevents a single well-connected entity from filling the page
# with its observations and relations.
if "type" not in params:
params["type"] = [SearchItemType.ENTITY.value]

# Resolve project parameter using the three-tier hierarchy
# allow_discovery=True enables Discovery Mode, so a project is not required
resolved_project = await resolve_project_parameter(project, allow_discovery=True)
Expand Down Expand Up @@ -271,7 +292,7 @@ async def recent_activity(
return _extract_recent_rows(activity_data)

# Format project-specific mode output
return _format_project_output(resolved_project, activity_data, timeframe, type)
return _format_project_output(resolved_project, activity_data, timeframe, type, page)


async def _get_project_activity(
Expand Down Expand Up @@ -312,9 +333,9 @@ async def _get_project_activity(
last_activity = current_time

# Extract folder from file_path
if hasattr(result.primary_result, "file_path") and result.primary_result.file_path:
folder = "/".join(result.primary_result.file_path.split("/")[:-1])
if folder:
if result.primary_result.file_path:
folder = str(PurePosixPath(result.primary_result.file_path).parent)
if folder and folder != ".":
active_folders.add(folder)

return ProjectActivity(
Expand All @@ -339,9 +360,7 @@ def _extract_recent_rows(
"title": primary.title,
"permalink": primary.permalink,
"file_path": primary.file_path,
"created_at": (
primary.created_at.isoformat() if getattr(primary, "created_at", None) else None
),
"created_at": primary.created_at.isoformat() if primary.created_at else None,
}
if project_name is not None:
row["project"] = project_name
Expand All @@ -365,7 +384,7 @@ def _format_discovery_output(
# Get latest activity from most active project
if most_active.activity.results:
latest = most_active.activity.results[0].primary_result
title = latest.title if hasattr(latest, "title") and latest.title else "Recent activity"
title = latest.title or "Recent activity"
# Format relative time
time_str = (
_format_relative_time(latest.created_at) if latest.created_at else "unknown time"
Expand Down Expand Up @@ -394,9 +413,7 @@ def _format_discovery_output(
for name, activity in projects_activity.items():
if activity.item_count > 0:
for result in activity.activity.results[:3]: # Top 3 from each active project
if result.primary_result.type == "entity" and hasattr(
result.primary_result, "title"
):
if result.primary_result.type == "entity":
title = result.primary_result.title
# Look for status indicators in titles
if any(word in title.lower() for word in ["complete", "fix", "test", "spec"]):
Expand Down Expand Up @@ -424,6 +441,7 @@ def _format_project_output(
activity_data: GraphContext,
timeframe: str,
type_filter: Union[str, List[str]],
page: int = 1,
) -> str:
"""Format project-specific mode output as human-readable text."""
lines = [f"## Recent Activity: {project_name} ({timeframe})"]
Expand All @@ -449,12 +467,12 @@ def _format_project_output(
if entities:
lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
for entity in entities[:5]: # Show top 5
title = entity.title if hasattr(entity, "title") and entity.title else "Untitled"
# Get folder from file_path if available
title = entity.title or "Untitled"
# Get folder from file_path
folder = ""
if hasattr(entity, "file_path") and entity.file_path:
folder_path = "/".join(entity.file_path.split("/")[:-1])
if folder_path:
if entity.file_path:
folder_path = str(PurePosixPath(entity.file_path).parent)
if folder_path and folder_path != ".":
folder = f" ({folder_path})"
lines.append(f" • {title}{folder}")

Expand All @@ -464,21 +482,15 @@ def _format_project_output(
# Group by category
by_category = {}
for obs in observations[:10]: # Limit to recent ones
category = (
getattr(obs, "category", "general") if hasattr(obs, "category") else "general"
)
category = obs.category
if category not in by_category:
by_category[category] = []
by_category[category].append(obs)

for category, obs_list in list(by_category.items())[:5]: # Show top 5 categories
lines.append(f" **{category}:** {len(obs_list)} items")
for obs in obs_list[:2]: # Show 2 examples per category
content = (
getattr(obs, "content", "No content")
if hasattr(obs, "content")
else "No content"
)
content = obs.content
# Truncate at word boundary
if len(content) > 80:
content = _truncate_at_word(content, 80)
Expand All @@ -488,28 +500,25 @@ def _format_project_output(
if relations:
lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
for rel in relations[:5]: # Show top 5
rel_type = (
getattr(rel, "relation_type", "relates_to")
if hasattr(rel, "relation_type")
else "relates_to"
)
from_entity = (
getattr(rel, "from_entity", "Unknown") if hasattr(rel, "from_entity") else "Unknown"
)
to_entity = getattr(rel, "to_entity", None) if hasattr(rel, "to_entity") else None
rel_type = rel.relation_type
from_entity = rel.from_entity or "Unknown"
to_entity = rel.to_entity

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

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

# Activity summary
# Activity summary with pagination guidance
total = len(activity_data.results)
lines.append(f"\n**Activity Summary:** {total} items found")
if hasattr(activity_data, "metadata") and activity_data.metadata:
if hasattr(activity_data.metadata, "total_results"):
lines.append(f"Total available: {activity_data.metadata.total_results}")
if activity_data.has_more:
lines.append(
f"\n**Activity Summary:** Showing {total} items (page {page}). "
f"Use page={page + 1} to see more."
)
else:
lines.append(f"\n**Activity Summary:** {total} items found.")

return "\n".join(lines)

Expand Down
1 change: 1 addition & 0 deletions src/basic_memory/schemas/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ class GraphContext(BaseModel):

page: Optional[int] = None
page_size: Optional[int] = None
has_more: bool = False


class ActivityStats(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions src/basic_memory/schemas/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,4 @@ class SearchResponse(BaseModel):
results: List[SearchResult]
current_page: int
page_size: int
has_more: bool = False
16 changes: 13 additions & 3 deletions src/basic_memory/services/context_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class ContextMetadata:
related_count: int = 0
total_observations: int = 0
total_relations: int = 0
has_more: bool = False


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

# Fetch one extra item to detect whether more pages exist (N+1 trick)
fetch_limit = limit + 1

normalized_path: Optional[str] = None
if memory_url:
path = memory_url_path(memory_url)
Expand All @@ -118,21 +122,26 @@ async def build_context(
normalized_path = "*".join(normalized_parts)
logger.debug(f"Pattern search for '{normalized_path}'")
primary = await self.search_repository.search(
permalink_match=normalized_path, limit=limit, offset=offset
permalink_match=normalized_path, limit=fetch_limit, offset=offset
)
else:
# For exact paths, normalize the whole thing
normalized_path = generate_permalink(path, split_extension=False)
logger.debug(f"Direct lookup for '{normalized_path}'")
primary = await self.search_repository.search(
permalink=normalized_path, limit=limit, offset=offset
permalink=normalized_path, limit=fetch_limit, offset=offset
)
else:
logger.debug(f"Build context for '{types}'")
primary = await self.search_repository.search(
search_item_types=types, after_date=since, limit=limit, offset=offset
search_item_types=types, after_date=since, limit=fetch_limit, offset=offset
)

# Trim to requested limit and set has_more flag
has_more = len(primary) > limit
if has_more:
primary = primary[:limit]

# Get type_id pairs for traversal

type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
Expand Down Expand Up @@ -171,6 +180,7 @@ async def build_context(
related_count=len(related),
total_observations=sum(len(obs) for obs in observations_by_entity.values()),
total_relations=sum(1 for r in related if r.type == SearchItemType.RELATION),
has_more=has_more,
)

# Build context results list directly with ContextResultItem objects
Expand Down
4 changes: 1 addition & 3 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ class ProjectService:

repository: ProjectRepository

def __init__(
self, repository: ProjectRepository, file_service: Optional["FileService"] = None
):
def __init__(self, repository: ProjectRepository, file_service: Optional["FileService"] = None):
"""Initialize the project service."""
super().__init__()
self.repository = repository
Expand Down
Loading
Loading