basic-memory 0.14.3__py3-none-any.whl → 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (90) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/api/routers/resource_router.py +3 -3
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +60 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +16 -4
  19. basic_memory/cli/commands/project.py +141 -145
  20. basic_memory/cli/commands/status.py +34 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +96 -20
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +20 -3
  27. basic_memory/file_utils.py +89 -0
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/importers/chatgpt_importer.py +1 -1
  30. basic_memory/importers/utils.py +2 -2
  31. basic_memory/markdown/entity_parser.py +2 -2
  32. basic_memory/markdown/markdown_processor.py +2 -2
  33. basic_memory/markdown/plugins.py +39 -21
  34. basic_memory/markdown/utils.py +1 -1
  35. basic_memory/mcp/async_client.py +22 -10
  36. basic_memory/mcp/project_context.py +141 -0
  37. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  38. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  39. basic_memory/mcp/prompts/recent_activity.py +116 -32
  40. basic_memory/mcp/prompts/search.py +1 -1
  41. basic_memory/mcp/prompts/utils.py +11 -4
  42. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  43. basic_memory/mcp/resources/project_info.py +20 -6
  44. basic_memory/mcp/server.py +0 -37
  45. basic_memory/mcp/tools/__init__.py +5 -6
  46. basic_memory/mcp/tools/build_context.py +39 -19
  47. basic_memory/mcp/tools/canvas.py +19 -8
  48. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  49. basic_memory/mcp/tools/delete_note.py +67 -34
  50. basic_memory/mcp/tools/edit_note.py +55 -39
  51. basic_memory/mcp/tools/headers.py +44 -0
  52. basic_memory/mcp/tools/list_directory.py +18 -8
  53. basic_memory/mcp/tools/move_note.py +119 -41
  54. basic_memory/mcp/tools/project_management.py +77 -229
  55. basic_memory/mcp/tools/read_content.py +28 -12
  56. basic_memory/mcp/tools/read_note.py +97 -57
  57. basic_memory/mcp/tools/recent_activity.py +441 -42
  58. basic_memory/mcp/tools/search.py +82 -70
  59. basic_memory/mcp/tools/sync_status.py +5 -4
  60. basic_memory/mcp/tools/utils.py +19 -0
  61. basic_memory/mcp/tools/view_note.py +31 -6
  62. basic_memory/mcp/tools/write_note.py +65 -14
  63. basic_memory/models/knowledge.py +19 -2
  64. basic_memory/models/project.py +6 -2
  65. basic_memory/repository/entity_repository.py +31 -84
  66. basic_memory/repository/project_repository.py +1 -1
  67. basic_memory/repository/relation_repository.py +13 -0
  68. basic_memory/repository/repository.py +2 -2
  69. basic_memory/repository/search_repository.py +9 -3
  70. basic_memory/schemas/__init__.py +6 -0
  71. basic_memory/schemas/base.py +70 -12
  72. basic_memory/schemas/cloud.py +46 -0
  73. basic_memory/schemas/memory.py +99 -18
  74. basic_memory/schemas/project_info.py +9 -10
  75. basic_memory/schemas/sync_report.py +48 -0
  76. basic_memory/services/context_service.py +35 -11
  77. basic_memory/services/directory_service.py +7 -0
  78. basic_memory/services/entity_service.py +82 -52
  79. basic_memory/services/initialization.py +30 -11
  80. basic_memory/services/project_service.py +23 -33
  81. basic_memory/sync/sync_service.py +148 -24
  82. basic_memory/sync/watch_service.py +128 -44
  83. basic_memory/utils.py +181 -109
  84. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
  85. basic_memory-0.15.0.dist-info/RECORD +147 -0
  86. basic_memory/mcp/project_session.py +0 -120
  87. basic_memory-0.14.3.dist-info/RECORD +0 -132
  88. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  89. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  90. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,23 +3,29 @@
3
3
  from typing import List, Union, Optional
4
4
 
5
5
  from loguru import logger
6
+ from fastmcp import Context
6
7
 
7
8
  from basic_memory.mcp.async_client import client
9
+ from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
8
10
  from basic_memory.mcp.server import mcp
9
11
  from basic_memory.mcp.tools.utils import call_get
10
- from basic_memory.mcp.project_session import get_active_project
11
12
  from basic_memory.schemas.base import TimeFrame
12
- from basic_memory.schemas.memory import GraphContext
13
+ from basic_memory.schemas.memory import (
14
+ GraphContext,
15
+ ProjectActivity,
16
+ ActivityStats,
17
+ )
18
+ from basic_memory.schemas.project_info import ProjectList, ProjectItem
13
19
  from basic_memory.schemas.search import SearchItemType
14
20
 
15
21
 
16
22
  @mcp.tool(
17
- description="""Get recent activity from across the knowledge base.
23
+ description="""Get recent activity for a project or across all projects.
18
24
 
19
25
  Timeframe supports natural language formats like:
20
- - "2 days ago"
26
+ - "2 days ago"
21
27
  - "last week"
22
- - "yesterday"
28
+ - "yesterday"
23
29
  - "today"
24
30
  - "3 weeks ago"
25
31
  Or standard formats like "7d"
@@ -29,12 +35,26 @@ async def recent_activity(
29
35
  type: Union[str, List[str]] = "",
30
36
  depth: int = 1,
31
37
  timeframe: TimeFrame = "7d",
32
- page: int = 1,
33
- page_size: int = 10,
34
- max_related: int = 10,
35
38
  project: Optional[str] = None,
36
- ) -> GraphContext:
37
- """Get recent activity across the knowledge base.
39
+ context: Context | None = None,
40
+ ) -> str:
41
+ """Get recent activity for a specific project or across all projects.
42
+
43
+ Project Resolution:
44
+ The server resolves projects in this order:
45
+ 1. Single Project Mode - server constrained to one project, parameter ignored
46
+ 2. Explicit project parameter - specify which project to query
47
+ 3. Default project - server configured default if no project specified
48
+
49
+ Discovery Mode:
50
+ When no specific project can be resolved, returns activity across all projects
51
+ to help discover available projects and their recent activity.
52
+
53
+ Project Discovery (when project is unknown):
54
+ 1. Call list_memory_projects() to see available projects
55
+ 2. Or use this tool without project parameter to see cross-project activity
56
+ 3. Ask the user which project to focus on
57
+ 4. Remember their choice for the conversation
38
58
 
39
59
  Args:
40
60
  type: Filter by content type(s). Can be a string or list of strings.
@@ -50,48 +70,39 @@ async def recent_activity(
50
70
  - Relative: "2 days ago", "last week", "yesterday"
51
71
  - Points in time: "2024-01-01", "January 1st"
52
72
  - Standard format: "7d", "24h"
53
- page: Page number of results to return (default: 1)
54
- page_size: Number of results to return per page (default: 10)
55
- max_related: Maximum number of related results to return (default: 10)
56
- project: Optional project name to get activity from. If not provided, uses current active project.
73
+ project: Project name to query. Optional - server will resolve using the
74
+ hierarchy above. If unknown, use list_memory_projects() to discover
75
+ available projects.
76
+ context: Optional FastMCP context for performance caching.
57
77
 
58
78
  Returns:
59
- GraphContext containing:
60
- - primary_results: Latest activities matching the filters
61
- - related_results: Connected content via relations
62
- - metadata: Query details and statistics
79
+ Human-readable summary of recent activity. When no specific project is
80
+ resolved, returns cross-project discovery information. When a specific
81
+ project is resolved, returns detailed activity for that project.
63
82
 
64
83
  Examples:
65
- # Get all entities for the last 10 days (default)
84
+ # Cross-project discovery mode
66
85
  recent_activity()
86
+ recent_activity(timeframe="yesterday")
67
87
 
68
- # Get all entities from yesterday (string format)
69
- recent_activity(type="entity", timeframe="yesterday")
70
-
71
- # Get all entities from yesterday (list format)
72
- recent_activity(type=["entity"], timeframe="yesterday")
88
+ # Project-specific activity
89
+ recent_activity(project="work-docs", type="entity", timeframe="yesterday")
90
+ recent_activity(project="research", type=["entity", "relation"], timeframe="today")
91
+ recent_activity(project="notes", type="entity", depth=2, timeframe="2 weeks ago")
73
92
 
74
- # Get recent relations and observations
75
- recent_activity(type=["relation", "observation"], timeframe="today")
76
-
77
- # Look back further with more context
78
- recent_activity(type="entity", depth=2, timeframe="2 weeks ago")
79
-
80
- # Get activity from specific project
81
- recent_activity(type="entity", project="work-project")
93
+ Raises:
94
+ ToolError: If project doesn't exist or type parameter contains invalid values
82
95
 
83
96
  Notes:
84
97
  - Higher depth values (>3) may impact performance with large result sets
85
98
  - For focused queries, consider using build_context with a specific URI
86
99
  - Max timeframe is 1 year in the past
87
100
  """
88
- logger.info(
89
- f"Getting recent activity from type={type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
90
- )
101
+ # Build common parameters for API calls
91
102
  params = {
92
- "page": page,
93
- "page_size": page_size,
94
- "max_related": max_related,
103
+ "page": 1,
104
+ "page_size": 10,
105
+ "max_related": 10,
95
106
  }
96
107
  if depth:
97
108
  params["depth"] = depth
@@ -120,12 +131,400 @@ async def recent_activity(
120
131
  # Add validated types to params
121
132
  params["type"] = [t.value for t in validated_types] # pyright: ignore
122
133
 
123
- active_project = get_active_project(project)
124
- project_url = active_project.project_url
134
+ # Resolve project parameter using the three-tier hierarchy
135
+ resolved_project = await resolve_project_parameter(project)
136
+
137
+ if resolved_project is None:
138
+ # Discovery Mode: Get activity across all projects
139
+ logger.info(
140
+ f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
141
+ )
142
+
143
+ # Get list of all projects
144
+ response = await call_get(client, "/projects/projects")
145
+ project_list = ProjectList.model_validate(response.json())
146
+
147
+ projects_activity = {}
148
+ total_items = 0
149
+ total_entities = 0
150
+ total_relations = 0
151
+ total_observations = 0
152
+ most_active_project = None
153
+ most_active_count = 0
154
+ active_projects = 0
155
+
156
+ # Query each project's activity
157
+ for project_info in project_list.projects:
158
+ project_activity = await _get_project_activity(client, project_info, params, depth)
159
+ projects_activity[project_info.name] = project_activity
160
+
161
+ # Aggregate stats
162
+ item_count = project_activity.item_count
163
+ if item_count > 0:
164
+ active_projects += 1
165
+ total_items += item_count
166
+
167
+ # Count by type
168
+ for result in project_activity.activity.results:
169
+ if result.primary_result.type == "entity":
170
+ total_entities += 1
171
+ elif result.primary_result.type == "relation":
172
+ total_relations += 1
173
+ elif result.primary_result.type == "observation":
174
+ total_observations += 1
175
+
176
+ # Track most active project
177
+ if item_count > most_active_count:
178
+ most_active_count = item_count
179
+ most_active_project = project_info.name
180
+
181
+ # Build summary stats
182
+ summary = ActivityStats(
183
+ total_projects=len(project_list.projects),
184
+ active_projects=active_projects,
185
+ most_active_project=most_active_project,
186
+ total_items=total_items,
187
+ total_entities=total_entities,
188
+ total_relations=total_relations,
189
+ total_observations=total_observations,
190
+ )
191
+
192
+ # Generate guidance for the assistant
193
+ guidance_lines = ["\n" + "─" * 40]
194
+
195
+ if most_active_project and most_active_count > 0:
196
+ guidance_lines.extend(
197
+ [
198
+ f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
199
+ f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
200
+ ]
201
+ )
202
+ elif active_projects > 0:
203
+ # Has activity but no clear most active project
204
+ active_project_names = [
205
+ name for name, activity in projects_activity.items() if activity.item_count > 0
206
+ ]
207
+ if len(active_project_names) == 1:
208
+ guidance_lines.extend(
209
+ [
210
+ f"Suggested project: '{active_project_names[0]}' (only active project)",
211
+ f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
212
+ ]
213
+ )
214
+ else:
215
+ guidance_lines.extend(
216
+ [
217
+ f"Multiple active projects found: {', '.join(active_project_names)}",
218
+ "Ask user: 'Which project should I use for this task?'",
219
+ ]
220
+ )
221
+ else:
222
+ # No recent activity
223
+ guidance_lines.extend(
224
+ [
225
+ "No recent activity found in any project.",
226
+ "Consider: Ask which project to use or if they want to create a new one.",
227
+ ]
228
+ )
229
+
230
+ guidance_lines.extend(
231
+ ["", "Session reminder: Remember their project choice throughout this conversation."]
232
+ )
233
+
234
+ guidance = "\n".join(guidance_lines)
235
+
236
+ # Format discovery mode output
237
+ return _format_discovery_output(projects_activity, summary, timeframe, guidance)
238
+
239
+ else:
240
+ # Project-Specific Mode: Get activity for specific project
241
+ logger.info(
242
+ f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
243
+ )
244
+
245
+ active_project = await get_active_project(client, resolved_project, context)
246
+ project_url = active_project.project_url
125
247
 
126
- response = await call_get(
248
+ response = await call_get(
249
+ client,
250
+ f"{project_url}/memory/recent",
251
+ params=params,
252
+ )
253
+ activity_data = GraphContext.model_validate(response.json())
254
+
255
+ # Format project-specific mode output
256
+ return _format_project_output(resolved_project, activity_data, timeframe, type)
257
+
258
+
259
+ async def _get_project_activity(
260
+ client, project_info: ProjectItem, params: dict, depth: int
261
+ ) -> ProjectActivity:
262
+ """Get activity data for a single project.
263
+
264
+ Args:
265
+ client: HTTP client for API calls
266
+ project_info: Project information
267
+ params: Query parameters for the activity request
268
+ depth: Graph traversal depth
269
+
270
+ Returns:
271
+ ProjectActivity with activity data or empty activity on error
272
+ """
273
+ project_url = f"/{project_info.permalink}"
274
+ activity_response = await call_get(
127
275
  client,
128
276
  f"{project_url}/memory/recent",
129
277
  params=params,
130
278
  )
131
- return GraphContext.model_validate(response.json())
279
+ activity = GraphContext.model_validate(activity_response.json())
280
+
281
+ # Extract last activity timestamp and active folders
282
+ last_activity = None
283
+ active_folders = set()
284
+
285
+ for result in activity.results:
286
+ if result.primary_result.created_at:
287
+ current_time = result.primary_result.created_at
288
+ try:
289
+ if last_activity is None or current_time > last_activity:
290
+ last_activity = current_time
291
+ except TypeError:
292
+ # Handle timezone comparison issues by skipping this comparison
293
+ if last_activity is None:
294
+ last_activity = current_time
295
+
296
+ # Extract folder from file_path
297
+ if hasattr(result.primary_result, "file_path") and result.primary_result.file_path:
298
+ folder = "/".join(result.primary_result.file_path.split("/")[:-1])
299
+ if folder:
300
+ active_folders.add(folder)
301
+
302
+ return ProjectActivity(
303
+ project_name=project_info.name,
304
+ project_path=project_info.path,
305
+ activity=activity,
306
+ item_count=len(activity.results),
307
+ last_activity=last_activity,
308
+ active_folders=list(active_folders)[:5], # Limit to top 5 folders
309
+ )
310
+
311
+
312
+ def _format_discovery_output(
313
+ projects_activity: dict, summary: ActivityStats, timeframe: str, guidance: str
314
+ ) -> str:
315
+ """Format discovery mode output as human-readable text."""
316
+ lines = [f"## Recent Activity Summary ({timeframe})"]
317
+
318
+ # Most active project section
319
+ if summary.most_active_project and summary.total_items > 0:
320
+ most_active = projects_activity[summary.most_active_project]
321
+ lines.append(
322
+ f"\n**Most Active Project:** {summary.most_active_project} ({most_active.item_count} items)"
323
+ )
324
+
325
+ # Get latest activity from most active project
326
+ if most_active.activity.results:
327
+ latest = most_active.activity.results[0].primary_result
328
+ title = latest.title if hasattr(latest, "title") and latest.title else "Recent activity"
329
+ # Format relative time
330
+ time_str = (
331
+ _format_relative_time(latest.created_at) if latest.created_at else "unknown time"
332
+ )
333
+ lines.append(f"- 🔧 **Latest:** {title} ({time_str})")
334
+
335
+ # Active folders
336
+ if most_active.active_folders:
337
+ folders = ", ".join(most_active.active_folders[:3])
338
+ lines.append(f"- 📋 **Focus areas:** {folders}")
339
+
340
+ # Other active projects
341
+ other_active = [
342
+ (name, activity)
343
+ for name, activity in projects_activity.items()
344
+ if activity.item_count > 0 and name != summary.most_active_project
345
+ ]
346
+
347
+ if other_active:
348
+ lines.append("\n**Other Active Projects:**")
349
+ for name, activity in sorted(other_active, key=lambda x: x[1].item_count, reverse=True)[:4]:
350
+ lines.append(f"- **{name}** ({activity.item_count} items)")
351
+
352
+ # Key developments - extract from recent entities
353
+ key_items = []
354
+ for name, activity in projects_activity.items():
355
+ if activity.item_count > 0:
356
+ for result in activity.activity.results[:3]: # Top 3 from each active project
357
+ if result.primary_result.type == "entity" and hasattr(
358
+ result.primary_result, "title"
359
+ ):
360
+ title = result.primary_result.title
361
+ # Look for status indicators in titles
362
+ if any(word in title.lower() for word in ["complete", "fix", "test", "spec"]):
363
+ key_items.append(title)
364
+
365
+ if key_items:
366
+ lines.append("\n**Key Developments:**")
367
+ for item in key_items[:5]: # Show top 5
368
+ status = "✅" if any(word in item.lower() for word in ["complete", "fix"]) else "🧪"
369
+ lines.append(f"- {status} **{item}**")
370
+
371
+ # Add summary stats
372
+ lines.append(
373
+ f"\n**Summary:** {summary.active_projects} active projects, {summary.total_items} recent items"
374
+ )
375
+
376
+ # Add guidance
377
+ lines.append(guidance)
378
+
379
+ return "\n".join(lines)
380
+
381
+
382
+ def _format_project_output(
383
+ project_name: str,
384
+ activity_data: GraphContext,
385
+ timeframe: str,
386
+ type_filter: Union[str, List[str]],
387
+ ) -> str:
388
+ """Format project-specific mode output as human-readable text."""
389
+ lines = [f"## Recent Activity: {project_name} ({timeframe})"]
390
+
391
+ if not activity_data.results:
392
+ lines.append(f"\nNo recent activity found in '{project_name}' project.")
393
+ return "\n".join(lines)
394
+
395
+ # Group results by type
396
+ entities = []
397
+ relations = []
398
+ observations = []
399
+
400
+ for result in activity_data.results:
401
+ if result.primary_result.type == "entity":
402
+ entities.append(result.primary_result)
403
+ elif result.primary_result.type == "relation":
404
+ relations.append(result.primary_result)
405
+ elif result.primary_result.type == "observation":
406
+ observations.append(result.primary_result)
407
+
408
+ # Show entities (notes/documents)
409
+ if entities:
410
+ lines.append(f"\n**📄 Recent Notes & Documents ({len(entities)}):**")
411
+ for entity in entities[:5]: # Show top 5
412
+ title = entity.title if hasattr(entity, "title") and entity.title else "Untitled"
413
+ # Get folder from file_path if available
414
+ folder = ""
415
+ if hasattr(entity, "file_path") and entity.file_path:
416
+ folder_path = "/".join(entity.file_path.split("/")[:-1])
417
+ if folder_path:
418
+ folder = f" ({folder_path})"
419
+ lines.append(f" • {title}{folder}")
420
+
421
+ # Show observations (categorized insights)
422
+ if observations:
423
+ lines.append(f"\n**🔍 Recent Observations ({len(observations)}):**")
424
+ # Group by category
425
+ by_category = {}
426
+ for obs in observations[:10]: # Limit to recent ones
427
+ category = (
428
+ getattr(obs, "category", "general") if hasattr(obs, "category") else "general"
429
+ )
430
+ if category not in by_category:
431
+ by_category[category] = []
432
+ by_category[category].append(obs)
433
+
434
+ for category, obs_list in list(by_category.items())[:5]: # Show top 5 categories
435
+ lines.append(f" **{category}:** {len(obs_list)} items")
436
+ for obs in obs_list[:2]: # Show 2 examples per category
437
+ content = (
438
+ getattr(obs, "content", "No content")
439
+ if hasattr(obs, "content")
440
+ else "No content"
441
+ )
442
+ # Truncate at word boundary
443
+ if len(content) > 80:
444
+ content = _truncate_at_word(content, 80)
445
+ lines.append(f" - {content}")
446
+
447
+ # Show relations (connections)
448
+ if relations:
449
+ lines.append(f"\n**🔗 Recent Connections ({len(relations)}):**")
450
+ for rel in relations[:5]: # Show top 5
451
+ rel_type = (
452
+ getattr(rel, "relation_type", "relates_to")
453
+ if hasattr(rel, "relation_type")
454
+ else "relates_to"
455
+ )
456
+ from_entity = (
457
+ getattr(rel, "from_entity", "Unknown") if hasattr(rel, "from_entity") else "Unknown"
458
+ )
459
+ to_entity = getattr(rel, "to_entity", None) if hasattr(rel, "to_entity") else None
460
+
461
+ # Format as WikiLinks to show they're readable notes
462
+ from_link = f"[[{from_entity}]]" if from_entity != "Unknown" else from_entity
463
+ to_link = f"[[{to_entity}]]" if to_entity else "[Missing Link]"
464
+
465
+ lines.append(f" • {from_link} → {rel_type} → {to_link}")
466
+
467
+ # Activity summary
468
+ total = len(activity_data.results)
469
+ lines.append(f"\n**Activity Summary:** {total} items found")
470
+ if hasattr(activity_data, "metadata") and activity_data.metadata:
471
+ if hasattr(activity_data.metadata, "total_results"):
472
+ lines.append(f"Total available: {activity_data.metadata.total_results}")
473
+
474
+ return "\n".join(lines)
475
+
476
+
477
+ def _format_relative_time(timestamp) -> str:
478
+ """Format timestamp as relative time like '2 hours ago'."""
479
+ try:
480
+ from datetime import datetime, timezone
481
+ from dateutil.relativedelta import relativedelta
482
+
483
+ if isinstance(timestamp, str):
484
+ # Parse ISO format timestamp
485
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
486
+ else:
487
+ dt = timestamp
488
+
489
+ now = datetime.now(timezone.utc)
490
+ if dt.tzinfo is None:
491
+ dt = dt.replace(tzinfo=timezone.utc)
492
+
493
+ # Use relativedelta for accurate time differences
494
+ diff = relativedelta(now, dt)
495
+
496
+ if diff.years > 0:
497
+ return f"{diff.years} year{'s' if diff.years > 1 else ''} ago"
498
+ elif diff.months > 0:
499
+ return f"{diff.months} month{'s' if diff.months > 1 else ''} ago"
500
+ elif diff.days > 0:
501
+ if diff.days == 1:
502
+ return "yesterday"
503
+ elif diff.days < 7:
504
+ return f"{diff.days} days ago"
505
+ else:
506
+ weeks = diff.days // 7
507
+ return f"{weeks} week{'s' if weeks > 1 else ''} ago"
508
+ elif diff.hours > 0:
509
+ return f"{diff.hours} hour{'s' if diff.hours > 1 else ''} ago"
510
+ elif diff.minutes > 0:
511
+ return f"{diff.minutes} minute{'s' if diff.minutes > 1 else ''} ago"
512
+ else:
513
+ return "just now"
514
+ except Exception:
515
+ return "recently"
516
+
517
+
518
+ def _truncate_at_word(text: str, max_length: int) -> str:
519
+ """Truncate text at word boundary."""
520
+ if len(text) <= max_length:
521
+ return text
522
+
523
+ # Find last space before max_length
524
+ truncated = text[:max_length]
525
+ last_space = truncated.rfind(" ")
526
+
527
+ if last_space > max_length * 0.7: # Only truncate at word if we're not losing too much
528
+ return text[:last_space] + "..."
529
+ else:
530
+ return text[: max_length - 3] + "..."