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
This commit is contained in:
Adam Moussa 2026-05-01 13:32:06 -04:00
parent aebf6c43c6
commit 0a4912aec7
6 changed files with 332 additions and 44 deletions

View file

@ -1,19 +1,22 @@
import json import json
import logging import logging
import os import os
import re
import boto3 import boto3
from slack_bolt import App from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_bolt.adapter.socket_mode import SocketModeHandler
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("exec-aide") logger = logging.getLogger("lauren")
secrets_client = boto3.client("secretsmanager") secrets_client = boto3.client("secretsmanager")
ssm_client = boto3.client("ssm") ssm_client = boto3.client("ssm")
lambda_client = boto3.client("lambda") lambda_client = boto3.client("lambda")
dynamodb = boto3.resource("dynamodb")
_config = None _config = None
_table = None
def get_slack_credentials(): def get_slack_credentials():
@ -41,15 +44,97 @@ def get_config():
return _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() creds = get_slack_credentials()
app = App(token=creds["botToken"]) 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") @app.event("message")
def handle_message(event, say): def handle_message(event, say):
if event.get("bot_id") or event.get("subtype"): if event.get("bot_id") or event.get("subtype"):
return 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() config = get_config()
if event.get("user") != config["adam_slack_user_id"]: if event.get("user") != config["adam_slack_user_id"]:
return return
@ -59,26 +144,12 @@ def handle_message(event, say):
return return
thread_ts = event.get("thread_ts") or event["ts"] thread_ts = event.get("thread_ts") or event["ts"]
channel = event["channel"] _dispatch_conversation(text, thread_ts, event["channel"], say, is_dm=True)
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,
}),
)
def main(): def main():
handler = SocketModeHandler(app, creds["appToken"]) handler = SocketModeHandler(app, creds["appToken"])
logger.info("Exec Aide listener starting (Socket Mode)") logger.info("Lauren listener starting (Socket Mode)")
handler.start() handler.start()

View file

@ -1,11 +1,11 @@
display_information: display_information:
name: Exec Aide name: Lauren
description: Adam's personal executive assistant — inbox monitoring, triage, and more. description: Executive assistant for Adam — inbox monitoring, triage, and coordination.
background_color: "#1a1a2e" background_color: "#6B4984"
features: features:
bot_user: bot_user:
display_name: Exec Aide display_name: Lauren
always_online: true always_online: true
app_home: app_home:
home_tab_enabled: false home_tab_enabled: false
@ -15,15 +15,23 @@ features:
oauth_config: oauth_config:
scopes: scopes:
bot: bot:
- app_mentions:read
- channels:history
- chat:write - chat:write
- groups:history
- im:history - im:history
- im:read - im:read
- im:write - im:write
- mpim:history
settings: settings:
event_subscriptions: event_subscriptions:
bot_events: bot_events:
- app_mention
- message.channels
- message.groups
- message.im - message.im
- message.mpim
org_deploy_enabled: false org_deploy_enabled: false
socket_mode_enabled: true socket_mode_enabled: true
token_rotation_enabled: false token_rotation_enabled: false

View file

@ -1,12 +1,20 @@
import json import json
import logging import logging
from decimal import Decimal
import boto3 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.dynamo import get_conversation, save_conversation
from shared.secrets import get_slack_token from shared.secrets import get_slack_token
from shared.slack import update_message from shared.slack import update_message, post_message
from conversation.tools import TOOLS, dispatch_tool from conversation.tools import TOOLS, dispatch_tool, set_tool_context
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -17,7 +25,7 @@ MODEL_ID = "us.anthropic.claude-sonnet-4-6"
MAX_TOOL_ITERATIONS = 10 MAX_TOOL_ITERATIONS = 10
SYSTEM_PROMPT = """\ 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). of Sea Haven Industries (a facilities maintenance company).
You have access to Adam's email inbox. Use the available tools to answer \ 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"). For time references, use relative terms (e.g., "2 hours ago", "yesterday").
If Adam asks about something outside your current capabilities (calendar, \ 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): def lambda_handler(event, context):
user_message = event["user_message"] user_message = event["user_message"]
thread_ts = event["thread_ts"] thread_ts = event.get("thread_ts")
channel = event["channel"] 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: 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}]}) 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) final_text = _extract_text(assistant_message)
save_conversation(thread_ts, messages) save_conversation(conv_key, messages)
update_message(channel, placeholder_ts, final_text, get_slack_token())
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: except Exception:
logger.exception("Conversation failed for thread %s", thread_ts) logger.exception("Conversation failed for thread %s", thread_ts)
try: try:
update_message( token = get_slack_token()
channel, placeholder_ts, error_msg = "Sorry, I hit an error processing that. Try again in a moment."
"Sorry, I hit an error processing that. Try again in a moment.", if placeholder_ts:
get_slack_token(), 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: 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 assistant_message = None
for _ in range(MAX_TOOL_ITERATIONS): for _ in range(MAX_TOOL_ITERATIONS):
response = _bedrock.converse( response = _bedrock.converse(
modelId=MODEL_ID, modelId=MODEL_ID,
system=[{"text": SYSTEM_PROMPT}], system=[{"text": system}],
messages=messages, messages=messages,
toolConfig={"tools": TOOLS}, toolConfig={"tools": TOOLS},
inferenceConfig={"maxTokens": 4096}, inferenceConfig={"maxTokens": 4096},
@ -88,7 +140,7 @@ def _run_conversation(messages):
tool_results.append({ tool_results.append({
"toolResult": { "toolResult": {
"toolUseId": tool_use["toolUseId"], "toolUseId": tool_use["toolUseId"],
"content": [{"text": json.dumps(result)}], "content": [{"text": json.dumps(result, cls=_DecimalEncoder)}],
} }
}) })
messages.append({"role": "user", "content": tool_results}) messages.append({"role": "user", "content": tool_results})

View file

@ -5,13 +5,23 @@ from datetime import datetime, timezone
import boto3 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.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__) logger = logging.getLogger(__name__)
_lambda_client = None _lambda_client = None
_tool_context = {}
def set_tool_context(channel, thread_ts):
global _tool_context
_tool_context = {"channel": channel, "thread_ts": thread_ts}
TOOLS = [ 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", ""), "snippet": msg.get("snippet", ""),
"classification": msg.get("classification", ""), "classification": msg.get("classification", ""),
"classification_reason": msg.get("classification_reason", ""), "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, "search_emails_by_sender": _handle_search_sender,
"get_email_thread_detail": _handle_thread_detail, "get_email_thread_detail": _handle_thread_detail,
"trigger_daily_digest": _handle_trigger_digest, "trigger_daily_digest": _handle_trigger_digest,
"search_inbox": _handle_search_inbox,
"coordinate_with_user": _handle_coordinate,
} }
handler = handlers.get(tool_name) handler = handlers.get(tool_name)
if not handler: if not handler:
@ -277,3 +333,56 @@ def _handle_trigger_digest(inputs):
InvocationType="Event", InvocationType="Event",
) )
return {"status": "triggered", "message": "Daily digest has been triggered and will arrive shortly."} 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.",
}

View file

@ -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): def get_unanswered_threads(threshold_hours):
cutoff = datetime.now(timezone.utc).timestamp() - (threshold_hours * 3600) cutoff = datetime.now(timezone.utc).timestamp() - (threshold_hours * 3600)
items = [] items = []

View file

@ -169,6 +169,22 @@ def send_message_to_channel(channel, text, token):
logger.error("chat.postMessage failed: %s", data.get("error")) 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): def update_message(channel, ts, text, token):
resp = requests.post( resp = requests.post(
"https://slack.com/api/chat.update", "https://slack.com/api/chat.update",