basic-memory 0.17.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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,534 @@
1
+ """Recent activity tool for Basic Memory MCP server."""
2
+
3
+ from typing import List, Union, Optional
4
+
5
+ from loguru import logger
6
+ from fastmcp import Context
7
+
8
+ from basic_memory.mcp.async_client import get_client
9
+ from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
10
+ from basic_memory.mcp.server import mcp
11
+ from basic_memory.mcp.tools.utils import call_get
12
+ from basic_memory.telemetry import track_mcp_tool
13
+ from basic_memory.schemas.base import TimeFrame
14
+ from basic_memory.schemas.memory import (
15
+ GraphContext,
16
+ ProjectActivity,
17
+ ActivityStats,
18
+ )
19
+ from basic_memory.schemas.project_info import ProjectList, ProjectItem
20
+ from basic_memory.schemas.search import SearchItemType
21
+
22
+
23
+ @mcp.tool(
24
+ description="""Get recent activity for a project or across all projects.
25
+
26
+ Timeframe supports natural language formats like:
27
+ - "2 days ago"
28
+ - "last week"
29
+ - "yesterday"
30
+ - "today"
31
+ - "3 weeks ago"
32
+ Or standard formats like "7d"
33
+ """,
34
+ )
35
+ async def recent_activity(
36
+ type: Union[str, List[str]] = "",
37
+ depth: int = 1,
38
+ timeframe: TimeFrame = "7d",
39
+ project: Optional[str] = None,
40
+ context: Context | None = None,
41
+ ) -> str:
42
+ """Get recent activity for a specific project or across all projects.
43
+
44
+ Project Resolution:
45
+ The server resolves projects in this order:
46
+ 1. Single Project Mode - server constrained to one project, parameter ignored
47
+ 2. Explicit project parameter - specify which project to query
48
+ 3. Default project - server configured default if no project specified
49
+
50
+ Discovery Mode:
51
+ When no specific project can be resolved, returns activity across all projects
52
+ to help discover available projects and their recent activity.
53
+
54
+ Project Discovery (when project is unknown):
55
+ 1. Call list_memory_projects() to see available projects
56
+ 2. Or use this tool without project parameter to see cross-project activity
57
+ 3. Ask the user which project to focus on
58
+ 4. Remember their choice for the conversation
59
+
60
+ Args:
61
+ type: Filter by content type(s). Can be a string or list of strings.
62
+ Valid options:
63
+ - "entity" or ["entity"] for knowledge entities
64
+ - "relation" or ["relation"] for connections between entities
65
+ - "observation" or ["observation"] for notes and observations
66
+ Multiple types can be combined: ["entity", "relation"]
67
+ Case-insensitive: "ENTITY" and "entity" are treated the same.
68
+ Default is an empty string, which returns all types.
69
+ depth: How many relation hops to traverse (1-3 recommended)
70
+ timeframe: Time window to search. Supports natural language:
71
+ - Relative: "2 days ago", "last week", "yesterday"
72
+ - Points in time: "2024-01-01", "January 1st"
73
+ - Standard format: "7d", "24h"
74
+ project: Project name to query. Optional - server will resolve using the
75
+ hierarchy above. If unknown, use list_memory_projects() to discover
76
+ available projects.
77
+ context: Optional FastMCP context for performance caching.
78
+
79
+ Returns:
80
+ Human-readable summary of recent activity. When no specific project is
81
+ resolved, returns cross-project discovery information. When a specific
82
+ project is resolved, returns detailed activity for that project.
83
+
84
+ Examples:
85
+ # Cross-project discovery mode
86
+ recent_activity()
87
+ recent_activity(timeframe="yesterday")
88
+
89
+ # Project-specific activity
90
+ recent_activity(project="work-docs", type="entity", timeframe="yesterday")
91
+ recent_activity(project="research", type=["entity", "relation"], timeframe="today")
92
+ recent_activity(project="notes", type="entity", depth=2, timeframe="2 weeks ago")
93
+
94
+ Raises:
95
+ ToolError: If project doesn't exist or type parameter contains invalid values
96
+
97
+ Notes:
98
+ - Higher depth values (>3) may impact performance with large result sets
99
+ - For focused queries, consider using build_context with a specific URI
100
+ - Max timeframe is 1 year in the past
101
+ """
102
+ track_mcp_tool("recent_activity")
103
+ async with get_client() as client:
104
+ # Build common parameters for API calls
105
+ params = {
106
+ "page": 1,
107
+ "page_size": 10,
108
+ "max_related": 10,
109
+ }
110
+ if depth:
111
+ params["depth"] = depth
112
+ if timeframe:
113
+ params["timeframe"] = timeframe # pyright: ignore
114
+
115
+ # Validate and convert type parameter
116
+ if type:
117
+ # Convert single string to list
118
+ if isinstance(type, str):
119
+ type_list = [type]
120
+ else:
121
+ type_list = type
122
+
123
+ # Validate each type against SearchItemType enum
124
+ validated_types = []
125
+ for t in type_list:
126
+ try:
127
+ # Try to convert string to enum
128
+ if isinstance(t, str):
129
+ validated_types.append(SearchItemType(t.lower()))
130
+ except ValueError:
131
+ valid_types = [t.value for t in SearchItemType]
132
+ raise ValueError(f"Invalid type: {t}. Valid types are: {valid_types}")
133
+
134
+ # Add validated types to params
135
+ params["type"] = [t.value for t in validated_types] # pyright: ignore
136
+
137
+ # Resolve project parameter using the three-tier hierarchy
138
+ resolved_project = await resolve_project_parameter(project)
139
+
140
+ if resolved_project is None:
141
+ # Discovery Mode: Get activity across all projects
142
+ logger.info(
143
+ f"Getting recent activity across all projects: type={type}, depth={depth}, timeframe={timeframe}"
144
+ )
145
+
146
+ # Get list of all projects
147
+ response = await call_get(client, "/projects/projects")
148
+ project_list = ProjectList.model_validate(response.json())
149
+
150
+ projects_activity = {}
151
+ total_items = 0
152
+ total_entities = 0
153
+ total_relations = 0
154
+ total_observations = 0
155
+ most_active_project = None
156
+ most_active_count = 0
157
+ active_projects = 0
158
+
159
+ # Query each project's activity
160
+ for project_info in project_list.projects:
161
+ project_activity = await _get_project_activity(client, project_info, params, depth)
162
+ projects_activity[project_info.name] = project_activity
163
+
164
+ # Aggregate stats
165
+ item_count = project_activity.item_count
166
+ if item_count > 0:
167
+ active_projects += 1
168
+ total_items += item_count
169
+
170
+ # Count by type
171
+ for result in project_activity.activity.results:
172
+ if result.primary_result.type == "entity":
173
+ total_entities += 1
174
+ elif result.primary_result.type == "relation":
175
+ total_relations += 1
176
+ elif result.primary_result.type == "observation":
177
+ total_observations += 1
178
+
179
+ # Track most active project
180
+ if item_count > most_active_count:
181
+ most_active_count = item_count
182
+ most_active_project = project_info.name
183
+
184
+ # Build summary stats
185
+ summary = ActivityStats(
186
+ total_projects=len(project_list.projects),
187
+ active_projects=active_projects,
188
+ most_active_project=most_active_project,
189
+ total_items=total_items,
190
+ total_entities=total_entities,
191
+ total_relations=total_relations,
192
+ total_observations=total_observations,
193
+ )
194
+
195
+ # Generate guidance for the assistant
196
+ guidance_lines = ["\n" + "─" * 40]
197
+
198
+ if most_active_project and most_active_count > 0:
199
+ guidance_lines.extend(
200
+ [
201
+ f"Suggested project: '{most_active_project}' (most active with {most_active_count} items)",
202
+ f"Ask user: 'Should I use {most_active_project} for this task, or would you prefer a different project?'",
203
+ ]
204
+ )
205
+ elif active_projects > 0:
206
+ # Has activity but no clear most active project
207
+ active_project_names = [
208
+ name for name, activity in projects_activity.items() if activity.item_count > 0
209
+ ]
210
+ if len(active_project_names) == 1:
211
+ guidance_lines.extend(
212
+ [
213
+ f"Suggested project: '{active_project_names[0]}' (only active project)",
214
+ f"Ask user: 'Should I use {active_project_names[0]} for this task?'",
215
+ ]
216
+ )
217
+ else:
218
+ guidance_lines.extend(
219
+ [
220
+ f"Multiple active projects found: {', '.join(active_project_names)}",
221
+ "Ask user: 'Which project should I use for this task?'",
222
+ ]
223
+ )
224
+ else:
225
+ # No recent activity
226
+ guidance_lines.extend(
227
+ [
228
+ "No recent activity found in any project.",
229
+ "Consider: Ask which project to use or if they want to create a new one.",
230
+ ]
231
+ )
232
+
233
+ guidance_lines.extend(
234
+ [
235
+ "",
236
+ "Session reminder: Remember their project choice throughout this conversation.",
237
+ ]
238
+ )
239
+
240
+ guidance = "\n".join(guidance_lines)
241
+
242
+ # Format discovery mode output
243
+ return _format_discovery_output(projects_activity, summary, timeframe, guidance)
244
+
245
+ else:
246
+ # Project-Specific Mode: Get activity for specific project
247
+ logger.info(
248
+ f"Getting recent activity from project {resolved_project}: type={type}, depth={depth}, timeframe={timeframe}"
249
+ )
250
+
251
+ active_project = await get_active_project(client, resolved_project, context)
252
+
253
+ response = await call_get(
254
+ client,
255
+ f"/v2/projects/{active_project.id}/memory/recent",
256
+ params=params,
257
+ )
258
+ activity_data = GraphContext.model_validate(response.json())
259
+
260
+ # Format project-specific mode output
261
+ return _format_project_output(resolved_project, activity_data, timeframe, type)
262
+
263
+
264
+ async def _get_project_activity(
265
+ client, project_info: ProjectItem, params: dict, depth: int
266
+ ) -> ProjectActivity:
267
+ """Get activity data for a single project.
268
+
269
+ Args:
270
+ client: HTTP client for API calls
271
+ project_info: Project information
272
+ params: Query parameters for the activity request
273
+ depth: Graph traversal depth
274
+
275
+ Returns:
276
+ ProjectActivity with activity data or empty activity on error
277
+ """
278
+ activity_response = await call_get(
279
+ client,
280
+ f"/v2/projects/{project_info.id}/memory/recent",
281
+ params=params,
282
+ )
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] + "..."