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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- 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] + "..."
|