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 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()

View file

@ -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

View file

@ -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})

View file

@ -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.",
}

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

View file

@ -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",