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