From 0a4912aec72d6b4a584bcd50c18def23ff04d76a Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 1 May 2026 13:32:06 -0400 Subject: [PATCH] Rename to Lauren, add @mention support and channel-aware privacy - Rename Exec Aide to Lauren across manifest and system prompt - Add app_mention handler for @mentions in channels and group DMs - Channel-aware system prompt: decline private info in public channels - Add coordination thread support (COORD# DynamoDB records) - Add search_inbox tool (full Gmail search via API) - Add coordinate_with_user tool (start threaded coordination) - Add post_message helper for flat/threaded channel replies - Fix Decimal serialization from DynamoDB in tool results - Use Slack mrkdwn formatting (single asterisks) instead of Markdown --- listener/app.py | 105 ++++++++++++++++++++++++++++------ slack-app-manifest.yaml | 16 ++++-- src/conversation/app.py | 92 +++++++++++++++++++++++------- src/conversation/tools.py | 115 +++++++++++++++++++++++++++++++++++++- src/shared/dynamo.py | 32 +++++++++++ src/shared/slack.py | 16 ++++++ 6 files changed, 332 insertions(+), 44 deletions(-) diff --git a/listener/app.py b/listener/app.py index a24ef4b..cd0ba43 100644 --- a/listener/app.py +++ b/listener/app.py @@ -1,19 +1,22 @@ import json import logging import os +import re import boto3 from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("exec-aide") +logger = logging.getLogger("lauren") secrets_client = boto3.client("secretsmanager") ssm_client = boto3.client("ssm") lambda_client = boto3.client("lambda") +dynamodb = boto3.resource("dynamodb") _config = None +_table = None def get_slack_credentials(): @@ -41,15 +44,97 @@ def get_config(): return _config +def _get_table(): + global _table + if _table is None: + _table = dynamodb.Table(os.environ["TABLE_NAME"]) + return _table + + +def _get_coordination(channel, thread_ts): + resp = _get_table().get_item( + Key={"pk": f"COORD#{channel}#{thread_ts}", "sk": "COORD"} + ) + item = resp.get("Item") + if item and item.get("status") == "active": + return item + return None + + +def _dispatch_conversation(text, thread_ts, channel, say, is_dm): + placeholder_ts = None + if is_dm: + result = say(text="Thinking...", thread_ts=thread_ts) + placeholder_ts = result["ts"] + + lambda_client.invoke( + FunctionName=os.environ["CONVERSATION_FN_ARN"], + InvocationType="Event", + Payload=json.dumps({ + "user_message": text, + "thread_ts": thread_ts, + "channel": channel, + "placeholder_ts": placeholder_ts, + "is_dm": is_dm, + }), + ) + + +def _dispatch_coordination_reply(event, coord): + lambda_client.invoke( + FunctionName=os.environ["CONVERSATION_FN_ARN"], + InvocationType="Event", + Payload=json.dumps({ + "user_message": event.get("text", ""), + "thread_ts": coord["conversation_thread_ts"], + "channel": event["channel"], + "is_dm": False, + "coordination": { + "coord_thread_ts": event["thread_ts"], + "reply_user_id": event.get("user"), + }, + }), + ) + + creds = get_slack_credentials() app = App(token=creds["botToken"]) +@app.event("app_mention") +def handle_mention(event, say, context): + config = get_config() + if event.get("user") != config["adam_slack_user_id"]: + return + + bot_id = context.get("bot_user_id", "") + text = re.sub(rf"<@{bot_id}>\s*", "", event.get("text", "")).strip() + if not text: + return + + thread_ts = event.get("thread_ts") + _dispatch_conversation(text, thread_ts, event["channel"], say, is_dm=False) + + @app.event("message") def handle_message(event, say): if event.get("bot_id") or event.get("subtype"): return + channel_type = event.get("channel_type", "") + + if channel_type != "im": + thread_ts = event.get("thread_ts") + if not thread_ts: + return + + coord = _get_coordination(event["channel"], thread_ts) + if not coord: + return + + _dispatch_coordination_reply(event, coord) + return + config = get_config() if event.get("user") != config["adam_slack_user_id"]: return @@ -59,26 +144,12 @@ def handle_message(event, say): return thread_ts = event.get("thread_ts") or event["ts"] - channel = event["channel"] - - result = say(text="Thinking...", thread_ts=thread_ts) - placeholder_ts = result["ts"] - - lambda_client.invoke( - FunctionName=os.environ["CONVERSATION_FN_ARN"], - InvocationType="Event", - Payload=json.dumps({ - "user_message": text, - "thread_ts": thread_ts, - "channel": channel, - "placeholder_ts": placeholder_ts, - }), - ) + _dispatch_conversation(text, thread_ts, event["channel"], say, is_dm=True) def main(): handler = SocketModeHandler(app, creds["appToken"]) - logger.info("Exec Aide listener starting (Socket Mode)") + logger.info("Lauren listener starting (Socket Mode)") handler.start() diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml index 8c098d3..556a9d6 100644 --- a/slack-app-manifest.yaml +++ b/slack-app-manifest.yaml @@ -1,11 +1,11 @@ display_information: - name: Exec Aide - description: Adam's personal executive assistant — inbox monitoring, triage, and more. - background_color: "#1a1a2e" + name: Lauren + description: Executive assistant for Adam — inbox monitoring, triage, and coordination. + background_color: "#6B4984" features: bot_user: - display_name: Exec Aide + display_name: Lauren always_online: true app_home: home_tab_enabled: false @@ -15,15 +15,23 @@ features: oauth_config: scopes: bot: + - app_mentions:read + - channels:history - chat:write + - groups:history - im:history - im:read - im:write + - mpim:history settings: event_subscriptions: bot_events: + - app_mention + - message.channels + - message.groups - message.im + - message.mpim org_deploy_enabled: false socket_mode_enabled: true token_rotation_enabled: false diff --git a/src/conversation/app.py b/src/conversation/app.py index 4a7330c..798ca12 100644 --- a/src/conversation/app.py +++ b/src/conversation/app.py @@ -1,12 +1,20 @@ 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 -from conversation.tools import TOOLS, dispatch_tool +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) @@ -17,7 +25,7 @@ MODEL_ID = "us.anthropic.claude-sonnet-4-6" MAX_TOOL_ITERATIONS = 10 SYSTEM_PROMPT = """\ -You are Exec Aide, a personal executive assistant for Adam Moussa, President \ +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 \ @@ -30,45 +38,89 @@ When referencing emails, always include the sender name and subject line. \ For time references, use relative terms (e.g., "2 hours ago", "yesterday"). If Adam asks about something outside your current capabilities (calendar, \ -tasks, etc.), acknowledge it and let him know it's not available yet.\ +tasks, etc.), acknowledge it and let him know it's not available yet. + +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["thread_ts"] + thread_ts = event.get("thread_ts") channel = event["channel"] - placeholder_ts = event["placeholder_ts"] + placeholder_ts = event.get("placeholder_ts") + is_dm = event.get("is_dm", True) + coordination = event.get("coordination") try: - messages = get_conversation(thread_ts) + 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}]}) - assistant_message = _run_conversation(messages) + set_tool_context(channel, thread_ts) + assistant_message = _run_conversation(messages, system) final_text = _extract_text(assistant_message) - save_conversation(thread_ts, messages) - update_message(channel, placeholder_ts, final_text, get_slack_token()) + save_conversation(conv_key, messages) - logger.info("Conversation complete: thread=%s, turns=%d", thread_ts, len(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: - update_message( - channel, placeholder_ts, - "Sorry, I hit an error processing that. Try again in a moment.", - get_slack_token(), - ) + 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 update error placeholder") + logger.exception("Failed to post error message") -def _run_conversation(messages): +def _run_conversation(messages, system): assistant_message = None for _ in range(MAX_TOOL_ITERATIONS): response = _bedrock.converse( modelId=MODEL_ID, - system=[{"text": SYSTEM_PROMPT}], + system=[{"text": system}], messages=messages, toolConfig={"tools": TOOLS}, inferenceConfig={"maxTokens": 4096}, @@ -88,7 +140,7 @@ def _run_conversation(messages): tool_results.append({ "toolResult": { "toolUseId": tool_use["toolUseId"], - "content": [{"text": json.dumps(result)}], + "content": [{"text": json.dumps(result, cls=_DecimalEncoder)}], } }) messages.append({"role": "user", "content": tool_results}) diff --git a/src/conversation/tools.py b/src/conversation/tools.py index 6857c59..d98157c 100644 --- a/src/conversation/tools.py +++ b/src/conversation/tools.py @@ -5,13 +5,23 @@ from datetime import datetime, timezone import boto3 -from shared.dynamo import get_todays_messages, get_unanswered_threads +from shared.dynamo import ( + get_todays_messages, get_unanswered_threads, save_coordination, +) from shared.gmail import get_authenticated_service, fetch_thread -from shared.secrets import get_config +from shared.secrets import get_config, get_slack_token +from shared.slack import post_message logger = logging.getLogger(__name__) _lambda_client = None +_tool_context = {} + + +def set_tool_context(channel, thread_ts): + global _tool_context + _tool_context = {"channel": channel, "thread_ts": thread_ts} + TOOLS = [ { @@ -129,6 +139,50 @@ TOOLS = [ }, } }, + { + "toolSpec": { + "name": "search_inbox", + "description": "Search Adam's Gmail inbox with a query. Supports Gmail search syntax: from:, to:, subject:, after:, before:, is:unread, label:, has:attachment, etc.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Gmail search query (e.g., 'from:jane after:2024/01/01 subject:invoice').", + }, + "max_results": { + "type": "integer", + "description": "Maximum results to return. Defaults to 10.", + }, + }, + "required": ["query"], + } + }, + } + }, + { + "toolSpec": { + "name": "coordinate_with_user", + "description": "Start a coordination thread to interact with someone in the channel. Posts a message in the channel and starts a threaded conversation with the mentioned person. Use when Adam asks you to coordinate, schedule, or communicate with someone.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "flat_message": { + "type": "string", + "description": "Brief message to post flat in the channel (e.g., 'Coordinating a meeting with Jane').", + }, + "thread_message": { + "type": "string", + "description": "The message to post in the thread, @mentioning the person (use Slack format <@USER_ID>).", + }, + }, + "required": ["flat_message", "thread_message"], + } + }, + } + }, ] @@ -153,7 +207,7 @@ def _format_email(msg): "snippet": msg.get("snippet", ""), "classification": msg.get("classification", ""), "classification_reason": msg.get("classification_reason", ""), - "internal_date": msg.get("internal_date", 0), + "internal_date": int(msg.get("internal_date", 0)), } @@ -166,6 +220,8 @@ def dispatch_tool(tool_name, tool_input): "search_emails_by_sender": _handle_search_sender, "get_email_thread_detail": _handle_thread_detail, "trigger_daily_digest": _handle_trigger_digest, + "search_inbox": _handle_search_inbox, + "coordinate_with_user": _handle_coordinate, } handler = handlers.get(tool_name) if not handler: @@ -277,3 +333,56 @@ def _handle_trigger_digest(inputs): InvocationType="Event", ) return {"status": "triggered", "message": "Daily digest has been triggered and will arrive shortly."} + + +def _handle_search_inbox(inputs): + service = get_authenticated_service() + query = inputs["query"] + max_results = inputs.get("max_results", 10) + + resp = service.users().messages().list( + userId="me", q=query, maxResults=max_results + ).execute() + + messages = [] + for msg_meta in resp.get("messages", []): + msg = service.users().messages().get( + userId="me", id=msg_meta["id"], format="metadata", + metadataHeaders=["From", "To", "Subject", "Date"], + ).execute() + headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]} + messages.append({ + "id": msg["id"], + "thread_id": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + }) + + return {"query": query, "count": len(messages), "messages": messages} + + +def _handle_coordinate(inputs): + ctx = _tool_context + if not ctx.get("channel"): + return {"error": "Coordination is only available in channels, not DMs."} + + token = get_slack_token() + channel = ctx["channel"] + + coord_ts = post_message(channel, inputs["flat_message"], token) + post_message(channel, inputs["thread_message"], token, thread_ts=coord_ts) + + save_coordination( + channel=channel, + coord_thread_ts=coord_ts, + conversation_thread_ts=ctx.get("thread_ts", ""), + started_by="lauren", + ) + + return { + "status": "coordinating", + "message": "Thread started. I'll follow up when they respond.", + } diff --git a/src/shared/dynamo.py b/src/shared/dynamo.py index 626ad2d..d1e3307 100644 --- a/src/shared/dynamo.py +++ b/src/shared/dynamo.py @@ -155,6 +155,38 @@ def save_conversation(thread_ts, messages): }) +def save_coordination(channel, coord_thread_ts, conversation_thread_ts, started_by): + _get_table().put_item(Item={ + "pk": f"COORD#{channel}#{coord_thread_ts}", + "sk": "COORD", + "conversation_thread_ts": conversation_thread_ts, + "channel": channel, + "started_by": started_by, + "status": "active", + "created_at": datetime.now(timezone.utc).isoformat(), + "ttl": int(time.time()) + (7 * 24 * 60 * 60), + }) + + +def get_coordination(channel, coord_thread_ts): + resp = _get_table().get_item( + Key={"pk": f"COORD#{channel}#{coord_thread_ts}", "sk": "COORD"} + ) + item = resp.get("Item") + if item and item.get("status") == "active": + return item + return None + + +def complete_coordination(channel, coord_thread_ts): + _get_table().update_item( + Key={"pk": f"COORD#{channel}#{coord_thread_ts}", "sk": "COORD"}, + UpdateExpression="SET #s = :done", + ExpressionAttributeNames={"#s": "status"}, + ExpressionAttributeValues={":done": "completed"}, + ) + + def get_unanswered_threads(threshold_hours): cutoff = datetime.now(timezone.utc).timestamp() - (threshold_hours * 3600) items = [] diff --git a/src/shared/slack.py b/src/shared/slack.py index ecb1267..1a72649 100644 --- a/src/shared/slack.py +++ b/src/shared/slack.py @@ -169,6 +169,22 @@ def send_message_to_channel(channel, text, token): logger.error("chat.postMessage failed: %s", data.get("error")) +def post_message(channel, text, token, thread_ts=None): + payload = {"channel": channel, "text": text} + if thread_ts: + payload["thread_ts"] = thread_ts + resp = requests.post( + "https://slack.com/api/chat.postMessage", + headers={"Authorization": f"Bearer {token}"}, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + if not data.get("ok"): + raise RuntimeError(f"chat.postMessage failed: {data.get('error')}") + return data["ts"] + + def update_message(channel, ts, text, token): resp = requests.post( "https://slack.com/api/chat.update",