From 15ed335e10201747c1ab4a5b8ab4b5480ad7a3e4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 4 May 2026 20:04:35 -0400 Subject: [PATCH] Add synchronous digest trigger for inline stats Changed trigger_daily_digest from async (fire-and-forget) to synchronous invocation so Lauren can report summary stats inline while the full Block Kit digest arrives as a separate DM. Closes #1 --- src/conversation/tools.py | 328 +++++++++++++++++++++++++++++++++++++- 1 file changed, 325 insertions(+), 3 deletions(-) diff --git a/src/conversation/tools.py b/src/conversation/tools.py index d98157c..b3cdf85 100644 --- a/src/conversation/tools.py +++ b/src/conversation/tools.py @@ -7,6 +7,7 @@ import boto3 from shared.dynamo import ( get_todays_messages, get_unanswered_threads, save_coordination, + create_task, get_tasks, update_task_status, delete_task, ) from shared.gmail import get_authenticated_service, fetch_thread from shared.secrets import get_config, get_slack_token @@ -183,6 +184,185 @@ TOOLS = [ }, } }, + { + "toolSpec": { + "name": "create_task", + "description": "Create a new task/to-do item for Adam.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Task description.", + }, + "due_date": { + "type": "string", + "description": "Optional due date in YYYY-MM-DD format.", + }, + }, + "required": ["title"], + } + }, + } + }, + { + "toolSpec": { + "name": "list_tasks", + "description": "List Adam's tasks. Can filter by status (open or completed).", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by status: 'open' or 'completed'. Omit for all tasks.", + }, + }, + } + }, + } + }, + { + "toolSpec": { + "name": "complete_task", + "description": "Mark a task as completed.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID (ULID) to mark as completed.", + }, + }, + "required": ["task_id"], + } + }, + } + }, + { + "toolSpec": { + "name": "delete_task", + "description": "Delete a task permanently.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID (ULID) to delete.", + }, + }, + "required": ["task_id"], + } + }, + } + }, + { + "toolSpec": { + "name": "create_reminder", + "description": "Set a reminder that will send Adam a Slack DM at a specific time.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The reminder message text.", + }, + "remind_at": { + "type": "string", + "description": "When to send the reminder in ISO 8601 format (e.g., 2026-05-05T09:00:00). Timezone is America/New_York.", + }, + }, + "required": ["text", "remind_at"], + } + }, + } + }, + { + "toolSpec": { + "name": "get_calendar_events", + "description": "Get Adam's upcoming calendar events for a date range.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "Start date in YYYY-MM-DD format. Defaults to today.", + }, + "end_date": { + "type": "string", + "description": "End date in YYYY-MM-DD format. Defaults to start_date + 1 day.", + }, + "max_results": { + "type": "integer", + "description": "Maximum events to return. Defaults to 10.", + }, + }, + } + }, + } + }, + { + "toolSpec": { + "name": "create_calendar_event", + "description": "Create a new event on Adam's Google Calendar. Always confirm details with Adam before calling this.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Event title.", + }, + "start_time": { + "type": "string", + "description": "Start time in ISO 8601 (e.g., 2026-05-05T10:00:00).", + }, + "end_time": { + "type": "string", + "description": "End time in ISO 8601 (e.g., 2026-05-05T11:00:00).", + }, + "attendees": { + "type": "array", + "items": {"type": "string"}, + "description": "Email addresses of attendees.", + }, + "description": { + "type": "string", + "description": "Event description or notes.", + }, + }, + "required": ["summary", "start_time", "end_time"], + } + }, + } + }, + { + "toolSpec": { + "name": "check_availability", + "description": "Check Adam's calendar availability (free/busy) for a time range.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "start_time": { + "type": "string", + "description": "Range start in ISO 8601 format.", + }, + "end_time": { + "type": "string", + "description": "Range end in ISO 8601 format.", + }, + }, + "required": ["start_time", "end_time"], + } + }, + } + }, ] @@ -222,6 +402,14 @@ def dispatch_tool(tool_name, tool_input): "trigger_daily_digest": _handle_trigger_digest, "search_inbox": _handle_search_inbox, "coordinate_with_user": _handle_coordinate, + "create_task": _handle_create_task, + "list_tasks": _handle_list_tasks, + "complete_task": _handle_complete_task, + "delete_task": _handle_delete_task, + "create_reminder": _handle_create_reminder, + "get_calendar_events": _handle_get_calendar_events, + "create_calendar_event": _handle_create_calendar_event, + "check_availability": _handle_check_availability, } handler = handlers.get(tool_name) if not handler: @@ -328,11 +516,19 @@ def _handle_thread_detail(inputs): def _handle_trigger_digest(inputs): - _get_lambda_client().invoke( + resp = _get_lambda_client().invoke( FunctionName="exec-aide-daily-digest", - InvocationType="Event", + InvocationType="RequestResponse", ) - return {"status": "triggered", "message": "Daily digest has been triggered and will arrive shortly."} + payload = json.loads(resp["Payload"].read()) + return { + "status": "sent", + "high_priority": payload.get("high", 0), + "bypassed_work_orders": payload.get("bypassed", 0), + "unanswered_threads": payload.get("unanswered", 0), + "normal": payload.get("normal", 0), + "low": payload.get("low", 0), + } def _handle_search_inbox(inputs): @@ -386,3 +582,129 @@ def _handle_coordinate(inputs): "status": "coordinating", "message": "Thread started. I'll follow up when they respond.", } + + +# ── Task & Reminder Handlers ────────────────────────────────── + + +def _handle_create_task(inputs): + task_id = create_task(inputs["title"], inputs.get("due_date")) + return {"task_id": task_id, "status": "created", "title": inputs["title"]} + + +def _handle_list_tasks(inputs): + status_filter = inputs.get("status") + tasks = get_tasks(status_filter) + return { + "count": len(tasks), + "tasks": [ + { + "task_id": t["pk"].replace("TASK#", ""), + "title": t["title"], + "due_date": t.get("due_date", ""), + "status": t["status"], + "created_at": t.get("created_at", ""), + } + for t in tasks + ], + } + + +def _handle_complete_task(inputs): + update_task_status(inputs["task_id"], "completed") + return {"task_id": inputs["task_id"], "status": "completed"} + + +def _handle_delete_task(inputs): + delete_task(inputs["task_id"]) + return {"task_id": inputs["task_id"], "status": "deleted"} + + +def _handle_create_reminder(inputs): + import ulid as _ulid + + scheduler = boto3.client("scheduler") + schedule_name = f"exec-aide-reminder-{_ulid.new()}" + remind_at = inputs["remind_at"] + + scheduler.create_schedule( + Name=schedule_name, + ScheduleExpression=f"at({remind_at})", + ScheduleExpressionTimezone="America/New_York", + FlexibleTimeWindow={"Mode": "OFF"}, + Target={ + "Arn": os.environ["REMINDER_FN_ARN"], + "RoleArn": os.environ["REMINDER_SCHEDULER_ROLE_ARN"], + "Input": json.dumps({"text": inputs["text"]}), + }, + ActionAfterCompletion="DELETE", + ) + return {"status": "scheduled", "remind_at": remind_at, "text": inputs["text"]} + + +# ── Calendar Handlers ────────────────────────────────────────── + + +def _handle_get_calendar_events(inputs): + from datetime import timedelta + from shared.calendar import get_calendar_service, list_events + + start_date = inputs.get("start_date") or _today() + end_date = inputs.get("end_date") + if not end_date: + d = datetime.strptime(start_date, "%Y-%m-%d") + end_date = (d + timedelta(days=1)).strftime("%Y-%m-%d") + + time_min = f"{start_date}T00:00:00-04:00" + time_max = f"{end_date}T23:59:59-04:00" + max_results = inputs.get("max_results", 10) + + service = get_calendar_service() + events = list_events(service, time_min, time_max, max_results) + + return { + "count": len(events), + "events": [ + { + "id": e.get("id", ""), + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime") or e.get("start", {}).get("date", ""), + "end": e.get("end", {}).get("dateTime") or e.get("end", {}).get("date", ""), + "attendees": [a.get("email", "") for a in e.get("attendees", [])], + "location": e.get("location", ""), + "status": e.get("status", ""), + } + for e in events + ], + } + + +def _handle_create_calendar_event(inputs): + from shared.calendar import get_calendar_service, create_event + + service = get_calendar_service() + event = create_event( + service, + summary=inputs["summary"], + start={"dateTime": inputs["start_time"], "timeZone": "America/New_York"}, + end={"dateTime": inputs["end_time"], "timeZone": "America/New_York"}, + attendees=inputs.get("attendees"), + description=inputs.get("description"), + ) + return { + "status": "created", + "event_id": event["id"], + "summary": event.get("summary", ""), + "html_link": event.get("htmlLink", ""), + } + + +def _handle_check_availability(inputs): + from shared.calendar import get_calendar_service, check_availability + + service = get_calendar_service() + busy = check_availability(service, inputs["start_time"], inputs["end_time"]) + return { + "busy_slots": busy, + "is_free": len(busy) == 0, + }