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:
parent
aebf6c43c6
commit
0a4912aec7
6 changed files with 332 additions and 44 deletions
105
listener/app.py
105
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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Reference in a new issue