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