11"""Recent activity tool for Basic Memory MCP server."""
22
3- from datetime import datetime , timezone
43from typing import List , Union , Optional
54
65from loguru import logger
1312from basic_memory .schemas .base import TimeFrame
1413from basic_memory .schemas .memory import (
1514 GraphContext ,
16- ProjectActivitySummary ,
1715 ProjectActivity ,
1816 ActivityStats ,
1917)
@@ -37,12 +35,9 @@ async def recent_activity(
3735 type : Union [str , List [str ]] = "" ,
3836 depth : int = 1 ,
3937 timeframe : TimeFrame = "7d" ,
40- page : int = 1 ,
41- page_size : int = 10 ,
42- max_related : int = 10 ,
4338 project : Optional [str ] = None ,
4439 context : Context | None = None ,
45- ) -> Union [ GraphContext , ProjectActivitySummary ] :
40+ ) -> str :
4641 """Get recent activity for a specific project or across all projects.
4742
4843 This tool works in two modes based on the project parameter:
@@ -67,9 +62,6 @@ async def recent_activity(
6762 - Relative: "2 days ago", "last week", "yesterday"
6863 - Points in time: "2024-01-01", "January 1st"
6964 - Standard format: "7d", "24h"
70- page: Page number of results to return (default: 1)
71- page_size: Number of results to return per page (default: 10)
72- max_related: Maximum number of related results to return (default: 10)
7365 project: Optional project name. If not provided, uses default_project (if default_project_mode=true)
7466 or returns activity across all projects for discovery.
7567 If unknown, use list_memory_projects() to discover available projects.
@@ -104,9 +96,9 @@ async def recent_activity(
10496 """
10597 # Build common parameters for API calls
10698 params = {
107- "page" : page ,
108- "page_size" : page_size ,
109- "max_related" : max_related ,
99+ "page" : 1 ,
100+ "page_size" : 10 ,
101+ "max_related" : 10 ,
110102 }
111103 if depth :
112104 params ["depth" ] = depth
@@ -237,18 +229,13 @@ async def recent_activity(
237229
238230 guidance = "\n " .join (guidance_lines )
239231
240- return ProjectActivitySummary (
241- projects = projects_activity ,
242- summary = summary ,
243- timeframe = str (timeframe ),
244- generated_at = datetime .now (timezone .utc ),
245- guidance = guidance ,
246- )
232+ # Format discovery mode output
233+ return _format_discovery_output (projects_activity , summary , timeframe , guidance )
247234
248235 else :
249236 # Project-Specific Mode: Get activity for specific project
250237 logger .info (
251- f"Getting recent activity from project { resolved_project } : type={ type } , depth={ depth } , timeframe={ timeframe } , page= { page } , page_size= { page_size } , max_related= { max_related } "
238+ f"Getting recent activity from project { resolved_project } : type={ type } , depth={ depth } , timeframe={ timeframe } "
252239 )
253240
254241 active_project = await get_active_project (client , resolved_project , context )
@@ -259,7 +246,10 @@ async def recent_activity(
259246 f"{ project_url } /memory/recent" ,
260247 params = params ,
261248 )
262- return GraphContext .model_validate (response .json ())
249+ activity_data = GraphContext .model_validate (response .json ())
250+
251+ # Format project-specific mode output
252+ return _format_project_output (resolved_project , activity_data , timeframe , type )
263253
264254
265255async def _get_project_activity (
@@ -313,3 +303,224 @@ async def _get_project_activity(
313303 last_activity = last_activity ,
314304 active_folders = list (active_folders )[:5 ], # Limit to top 5 folders
315305 )
306+
307+
308+ def _format_discovery_output (
309+ projects_activity : dict , summary : ActivityStats , timeframe : str , guidance : str
310+ ) -> str :
311+ """Format discovery mode output as human-readable text."""
312+ lines = [f"## Recent Activity Summary ({ timeframe } )" ]
313+
314+ # Most active project section
315+ if summary .most_active_project and summary .total_items > 0 :
316+ most_active = projects_activity [summary .most_active_project ]
317+ lines .append (
318+ f"\n **Most Active Project:** { summary .most_active_project } ({ most_active .item_count } items)"
319+ )
320+
321+ # Get latest activity from most active project
322+ if most_active .activity .results :
323+ latest = most_active .activity .results [0 ].primary_result
324+ title = latest .title if hasattr (latest , "title" ) and latest .title else "Recent activity"
325+ # Format relative time
326+ time_str = (
327+ _format_relative_time (latest .created_at ) if latest .created_at else "unknown time"
328+ )
329+ lines .append (f"- 🔧 **Latest:** { title } ({ time_str } )" )
330+
331+ # Active folders
332+ if most_active .active_folders :
333+ folders = ", " .join (most_active .active_folders [:3 ])
334+ lines .append (f"- 📋 **Focus areas:** { folders } " )
335+
336+ # Other active projects
337+ other_active = [
338+ (name , activity )
339+ for name , activity in projects_activity .items ()
340+ if activity .item_count > 0 and name != summary .most_active_project
341+ ]
342+
343+ if other_active :
344+ lines .append ("\n **Other Active Projects:**" )
345+ for name , activity in sorted (other_active , key = lambda x : x [1 ].item_count , reverse = True )[:4 ]:
346+ lines .append (f"- **{ name } ** ({ activity .item_count } items)" )
347+
348+ # Key developments - extract from recent entities
349+ key_items = []
350+ for name , activity in projects_activity .items ():
351+ if activity .item_count > 0 :
352+ for result in activity .activity .results [:3 ]: # Top 3 from each active project
353+ if result .primary_result .type == "entity" and hasattr (
354+ result .primary_result , "title"
355+ ):
356+ title = result .primary_result .title
357+ # Look for status indicators in titles
358+ if any (word in title .lower () for word in ["complete" , "fix" , "test" , "spec" ]):
359+ key_items .append (title )
360+
361+ if key_items :
362+ lines .append ("\n **Key Developments:**" )
363+ for item in key_items [:5 ]: # Show top 5
364+ status = "✅" if any (word in item .lower () for word in ["complete" , "fix" ]) else "🧪"
365+ lines .append (f"- { status } **{ item } **" )
366+
367+ # Add summary stats
368+ lines .append (
369+ f"\n **Summary:** { summary .active_projects } active projects, { summary .total_items } recent items"
370+ )
371+
372+ # Add guidance
373+ lines .append (guidance )
374+
375+ return "\n " .join (lines )
376+
377+
378+ def _format_project_output (
379+ project_name : str ,
380+ activity_data : GraphContext ,
381+ timeframe : str ,
382+ type_filter : Union [str , List [str ]],
383+ ) -> str :
384+ """Format project-specific mode output as human-readable text."""
385+ lines = [f"## Recent Activity: { project_name } ({ timeframe } )" ]
386+
387+ if not activity_data .results :
388+ lines .append (f"\n No recent activity found in '{ project_name } ' project." )
389+ return "\n " .join (lines )
390+
391+ # Group results by type
392+ entities = []
393+ relations = []
394+ observations = []
395+
396+ for result in activity_data .results :
397+ if result .primary_result .type == "entity" :
398+ entities .append (result .primary_result )
399+ elif result .primary_result .type == "relation" :
400+ relations .append (result .primary_result )
401+ elif result .primary_result .type == "observation" :
402+ observations .append (result .primary_result )
403+
404+ # Show entities (notes/documents)
405+ if entities :
406+ lines .append (f"\n **📄 Recent Notes & Documents ({ len (entities )} ):**" )
407+ for entity in entities [:5 ]: # Show top 5
408+ title = entity .title if hasattr (entity , "title" ) and entity .title else "Untitled"
409+ # Get folder from file_path if available
410+ folder = ""
411+ if hasattr (entity , "file_path" ) and entity .file_path :
412+ folder_path = "/" .join (entity .file_path .split ("/" )[:- 1 ])
413+ if folder_path :
414+ folder = f" ({ folder_path } )"
415+ lines .append (f" • { title } { folder } " )
416+
417+ # Show observations (categorized insights)
418+ if observations :
419+ lines .append (f"\n **🔍 Recent Observations ({ len (observations )} ):**" )
420+ # Group by category
421+ by_category = {}
422+ for obs in observations [:10 ]: # Limit to recent ones
423+ category = (
424+ getattr (obs , "category" , "general" ) if hasattr (obs , "category" ) else "general"
425+ )
426+ if category not in by_category :
427+ by_category [category ] = []
428+ by_category [category ].append (obs )
429+
430+ for category , obs_list in list (by_category .items ())[:5 ]: # Show top 5 categories
431+ lines .append (f" **{ category } :** { len (obs_list )} items" )
432+ for obs in obs_list [:2 ]: # Show 2 examples per category
433+ content = (
434+ getattr (obs , "content" , "No content" )
435+ if hasattr (obs , "content" )
436+ else "No content"
437+ )
438+ # Truncate at word boundary
439+ if len (content ) > 80 :
440+ content = _truncate_at_word (content , 80 )
441+ lines .append (f" - { content } " )
442+
443+ # Show relations (connections)
444+ if relations :
445+ lines .append (f"\n **🔗 Recent Connections ({ len (relations )} ):**" )
446+ for rel in relations [:5 ]: # Show top 5
447+ rel_type = (
448+ getattr (rel , "relation_type" , "relates_to" )
449+ if hasattr (rel , "relation_type" )
450+ else "relates_to"
451+ )
452+ from_entity = (
453+ getattr (rel , "from_entity" , "Unknown" ) if hasattr (rel , "from_entity" ) else "Unknown"
454+ )
455+ to_entity = getattr (rel , "to_entity" , None ) if hasattr (rel , "to_entity" ) else None
456+
457+ # Format as WikiLinks to show they're readable notes
458+ from_link = f"[[{ from_entity } ]]" if from_entity != "Unknown" else from_entity
459+ to_link = f"[[{ to_entity } ]]" if to_entity else "[Missing Link]"
460+
461+ lines .append (f" • { from_link } → { rel_type } → { to_link } " )
462+
463+ # Activity summary
464+ total = len (activity_data .results )
465+ lines .append (f"\n **Activity Summary:** { total } items found" )
466+ if hasattr (activity_data , "metadata" ) and activity_data .metadata :
467+ if hasattr (activity_data .metadata , "total_results" ):
468+ lines .append (f"Total available: { activity_data .metadata .total_results } " )
469+
470+ return "\n " .join (lines )
471+
472+
473+ def _format_relative_time (timestamp ) -> str :
474+ """Format timestamp as relative time like '2 hours ago'."""
475+ try :
476+ from datetime import datetime , timezone
477+ from dateutil .relativedelta import relativedelta
478+
479+ if isinstance (timestamp , str ):
480+ # Parse ISO format timestamp
481+ dt = datetime .fromisoformat (timestamp .replace ("Z" , "+00:00" ))
482+ else :
483+ dt = timestamp
484+
485+ now = datetime .now (timezone .utc )
486+ if dt .tzinfo is None :
487+ dt = dt .replace (tzinfo = timezone .utc )
488+
489+ # Use relativedelta for accurate time differences
490+ diff = relativedelta (now , dt )
491+
492+ if diff .years > 0 :
493+ return f"{ diff .years } year{ 's' if diff .years > 1 else '' } ago"
494+ elif diff .months > 0 :
495+ return f"{ diff .months } month{ 's' if diff .months > 1 else '' } ago"
496+ elif diff .days > 0 :
497+ if diff .days == 1 :
498+ return "yesterday"
499+ elif diff .days < 7 :
500+ return f"{ diff .days } days ago"
501+ else :
502+ weeks = diff .days // 7
503+ return f"{ weeks } week{ 's' if weeks > 1 else '' } ago"
504+ elif diff .hours > 0 :
505+ return f"{ diff .hours } hour{ 's' if diff .hours > 1 else '' } ago"
506+ elif diff .minutes > 0 :
507+ return f"{ diff .minutes } minute{ 's' if diff .minutes > 1 else '' } ago"
508+ else :
509+ return "just now"
510+ except Exception :
511+ return "recently"
512+
513+
514+ def _truncate_at_word (text : str , max_length : int ) -> str :
515+ """Truncate text at word boundary."""
516+ if len (text ) <= max_length :
517+ return text
518+
519+ # Find last space before max_length
520+ truncated = text [:max_length ]
521+ last_space = truncated .rfind (" " )
522+
523+ if last_space > max_length * 0.7 : # Only truncate at word if we're not losing too much
524+ return text [:last_space ] + "..."
525+ else :
526+ return text [: max_length - 3 ] + "..."
0 commit comments