Skip to content

Commit 8cb3568

Browse files
committed
convert recent_activity output to text, add code review feedback
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 71c1b72 commit 8cb3568

4 files changed

Lines changed: 282 additions & 129 deletions

File tree

src/basic_memory/cli/commands/tool.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,30 +210,21 @@ def recent_activity(
210210
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
211211
depth: Optional[int] = 1,
212212
timeframe: Optional[TimeFrame] = "7d",
213-
page: int = 1,
214-
page_size: int = 10,
215-
max_related: int = 10,
216213
):
217214
"""Get recent activity across the knowledge base."""
218215
try:
219-
context = asyncio.run(
216+
result = asyncio.run(
220217
mcp_recent_activity.fn(
221218
type=type, # pyright: ignore [reportArgumentType]
222219
depth=depth,
223220
timeframe=timeframe,
224-
page=page,
225-
page_size=page_size,
226-
max_related=max_related,
227221
)
228222
)
229-
# Use json module for more controlled serialization
230-
import json
231-
232-
context_dict = context.model_dump(exclude_none=True)
233-
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
223+
# The tool now returns a formatted string directly
224+
print(result)
234225
except Exception as e: # pragma: no cover
235226
if not isinstance(e, typer.Exit):
236-
typer.echo(f"Error during build_context: {e}", err=True)
227+
typer.echo(f"Error during recent_activity: {e}", err=True)
237228
raise typer.Exit(1)
238229
raise
239230

src/basic_memory/mcp/tools/recent_activity.py

Lines changed: 232 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Recent activity tool for Basic Memory MCP server."""
22

3-
from datetime import datetime, timezone
43
from typing import List, Union, Optional
54

65
from loguru import logger
@@ -13,7 +12,6 @@
1312
from basic_memory.schemas.base import TimeFrame
1413
from 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

265255
async 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"\nNo 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

Comments
 (0)