11"""Recent activity tool for Basic Memory MCP server."""
22
33from datetime import timezone
4+ from pathlib import PurePosixPath
45from typing import List , Union , Optional , Literal
56
67from 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
277298async 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
0 commit comments