* Fix flat replies in DMs DM replies were threaded under Adam's message instead of appearing flat. Root cause: listener fell back to event["ts"] as thread_ts for new DMs, causing say() to post as a threaded reply. Removed the fallback so DMs always use flat messages with placeholder update. Closes #4 * 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 * Add tasks and reminders - DynamoDB task CRUD with TASK#{ulid} prefix - 5 new tools: create_task, list_tasks, complete_task, delete_task, create_reminder - New exec-aide-reminder Lambda triggered by EventBridge Scheduler one-shots - CDK: reminder Lambda, scheduler IAM role, conversation Lambda permissions - Updated system prompt with task/reminder capabilities Closes #3 * Add Google Calendar integration - New src/shared/calendar.py: OAuth service builder, list_events, create_event, check_availability (reuses gmail-oauth secret) - 3 new tools: get_calendar_events, create_calendar_event, check_availability - Extended OAuth scopes to include calendar in gmail.py and token script - Updated system prompt with calendar capabilities Requires re-running scripts/get_gmail_token.py to grant the calendar scope and updating the exec-aide/gmail-oauth secret with the new refresh token. Closes #2 * Add CodeBuild CI/CD pipeline CodePipeline triggers on pushes to main, CodeBuild runs cdk deploy via buildspec. * Update README for tasks, reminders, calendar, and CI/CD pipeline
164 lines
6.2 KiB
Python
164 lines
6.2 KiB
Python
import json
|
|
import logging
|
|
from decimal import Decimal
|
|
|
|
import boto3
|
|
|
|
|
|
class _DecimalEncoder(json.JSONEncoder):
|
|
def default(self, o):
|
|
if isinstance(o, Decimal):
|
|
return int(o) if o == int(o) else float(o)
|
|
return super().default(o)
|
|
|
|
from shared.dynamo import get_conversation, save_conversation
|
|
from shared.secrets import get_slack_token
|
|
from shared.slack import update_message, post_message
|
|
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
|
|
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
|
|
_bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
|
|
MODEL_ID = "us.anthropic.claude-sonnet-4-6"
|
|
MAX_TOOL_ITERATIONS = 10
|
|
|
|
SYSTEM_PROMPT = """\
|
|
You are Lauren, a personal executive assistant for Adam Moussa, President \
|
|
of Sea Haven Industries (a facilities maintenance company).
|
|
|
|
You have access to Adam's email inbox. Use the available tools to answer \
|
|
questions about emails, find information, and provide inbox summaries.
|
|
|
|
Be concise and direct. Adam is busy - give him the key information without \
|
|
unnecessary preamble. Use bullet points for lists of emails.
|
|
|
|
When referencing emails, always include the sender name and subject line. \
|
|
For time references, use relative terms (e.g., "2 hours ago", "yesterday").
|
|
|
|
You can manage Adam's task list — create tasks, list them, mark them \
|
|
complete, or delete them. You can also set reminders that will send a Slack \
|
|
DM at a specified time. When Adam asks to be reminded of something, use \
|
|
create_reminder with the appropriate date/time (default timezone: \
|
|
America/New_York).
|
|
|
|
You have access to Adam's Google Calendar. You can view upcoming events, \
|
|
check availability, and create new events. Always confirm event details \
|
|
(time, attendees, title) with Adam before creating. Default timezone: \
|
|
America/New_York. Use ISO 8601 format for all times sent to calendar tools.
|
|
|
|
Speak naturally and casually, like a capable coworker - not a bot or AI. \
|
|
Keep responses conversational.
|
|
|
|
FORMATTING: You are posting in Slack, which uses mrkdwn (NOT standard Markdown). \
|
|
Use *single asterisks* for bold, _underscores_ for italic. \
|
|
Do NOT use **double asterisks** or __double underscores__ - they will not render. \
|
|
Bullet points with - or • are fine. Slack emoji codes like :warning: work.\
|
|
"""
|
|
|
|
CHANNEL_ADDENDUM = """\
|
|
|
|
IMPORTANT: You are currently in a Slack channel visible to others - NOT a \
|
|
private DM. NEVER share Adam's private information (emails, inbox contents, \
|
|
calendar, schedule, or any personal data) in this context. You may only \
|
|
discuss information that Adam has explicitly shared in this conversation. \
|
|
If someone asks about Adam's private information, politely decline: \
|
|
"I can only discuss that with Adam directly."
|
|
|
|
You CAN help with coordination tasks visible to the channel - scheduling \
|
|
meetings, answering general questions, and interacting with other people \
|
|
when Adam asks you to.\
|
|
"""
|
|
|
|
|
|
def lambda_handler(event, context):
|
|
user_message = event["user_message"]
|
|
thread_ts = event.get("thread_ts")
|
|
channel = event["channel"]
|
|
placeholder_ts = event.get("placeholder_ts")
|
|
is_dm = event.get("is_dm", True)
|
|
coordination = event.get("coordination")
|
|
|
|
try:
|
|
system = SYSTEM_PROMPT
|
|
if not is_dm:
|
|
system += CHANNEL_ADDENDUM
|
|
|
|
conv_key = thread_ts or channel
|
|
messages = get_conversation(conv_key)
|
|
messages.append({"role": "user", "content": [{"text": user_message}]})
|
|
|
|
set_tool_context(channel, thread_ts)
|
|
assistant_message = _run_conversation(messages, system)
|
|
|
|
final_text = _extract_text(assistant_message)
|
|
save_conversation(conv_key, messages)
|
|
|
|
token = get_slack_token()
|
|
if placeholder_ts:
|
|
update_message(channel, placeholder_ts, final_text, token)
|
|
elif coordination:
|
|
post_message(channel, final_text, token,
|
|
thread_ts=coordination["coord_thread_ts"])
|
|
elif thread_ts:
|
|
post_message(channel, final_text, token, thread_ts=thread_ts)
|
|
else:
|
|
post_message(channel, final_text, token)
|
|
|
|
logger.info("Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
|
|
channel, thread_ts, len(messages), is_dm)
|
|
except Exception:
|
|
logger.exception("Conversation failed for thread %s", thread_ts)
|
|
try:
|
|
token = get_slack_token()
|
|
error_msg = "Sorry, I hit an error processing that. Try again in a moment."
|
|
if placeholder_ts:
|
|
update_message(channel, placeholder_ts, error_msg, token)
|
|
elif thread_ts:
|
|
post_message(channel, error_msg, token, thread_ts=thread_ts)
|
|
else:
|
|
post_message(channel, error_msg, token)
|
|
except Exception:
|
|
logger.exception("Failed to post error message")
|
|
|
|
|
|
def _run_conversation(messages, system):
|
|
assistant_message = None
|
|
for _ in range(MAX_TOOL_ITERATIONS):
|
|
response = _bedrock.converse(
|
|
modelId=MODEL_ID,
|
|
system=[{"text": system}],
|
|
messages=messages,
|
|
toolConfig={"tools": TOOLS},
|
|
inferenceConfig={"maxTokens": 4096},
|
|
)
|
|
assistant_message = response["output"]["message"]
|
|
messages.append(assistant_message)
|
|
|
|
if response["stopReason"] != "tool_use":
|
|
break
|
|
|
|
tool_results = []
|
|
for block in assistant_message["content"]:
|
|
tool_use = block.get("toolUse")
|
|
if tool_use:
|
|
logger.info("Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"]))
|
|
result = dispatch_tool(tool_use["name"], tool_use["input"])
|
|
tool_results.append({
|
|
"toolResult": {
|
|
"toolUseId": tool_use["toolUseId"],
|
|
"content": [{"text": json.dumps(result, cls=_DecimalEncoder)}],
|
|
}
|
|
})
|
|
messages.append({"role": "user", "content": tool_results})
|
|
|
|
return assistant_message
|
|
|
|
|
|
def _extract_text(message):
|
|
parts = []
|
|
for block in message.get("content", []):
|
|
if "text" in block:
|
|
parts.append(block["text"])
|
|
return "\n".join(parts) if parts else "I processed your request but have nothing to report."
|