From b950fe48d63bfb15a44a29e88cc1bdd8e65c140c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:17:23 +0000 Subject: [PATCH 01/11] Update slack-bolt requirement from >=1.18 to >=1.28.0 in /listener Updates the requirements on [slack-bolt](https://github.com/slackapi/bolt-python) to permit the latest version. - [Release notes](https://github.com/slackapi/bolt-python/releases) - [Commits](https://github.com/slackapi/bolt-python/compare/v1.18.0...v1.28.0) --- updated-dependencies: - dependency-name: slack-bolt dependency-version: 1.28.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- listener/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/listener/requirements.txt b/listener/requirements.txt index 5e1612b..fe7bb17 100644 --- a/listener/requirements.txt +++ b/listener/requirements.txt @@ -1,3 +1,3 @@ -slack-bolt>=1.18 +slack-bolt>=1.28.0 slack-sdk>=3.27 boto3>=1.34 From d10f17d5d674c5c17799fa528ee94e15380ca948 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:17:26 +0000 Subject: [PATCH 02/11] Update google-api-python-client requirement in /src Updates the requirements on [google-api-python-client](https://github.com/googleapis/google-api-python-client) to permit the latest version. - [Release notes](https://github.com/googleapis/google-api-python-client/releases) - [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.0.0...v2.195.0) --- updated-dependencies: - dependency-name: google-api-python-client dependency-version: 2.195.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/requirements.txt b/src/requirements.txt index 871567c..06594cd 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,3 @@ -google-api-python-client>=2.0 +google-api-python-client>=2.195.0 google-auth>=2.0 requests>=2.31 From c85c221dfc615c1da582b477edebf50113b37a34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:17:30 +0000 Subject: [PATCH 03/11] Update requests requirement from >=2.31 to >=2.33.1 in /src Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.33.1) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/requirements.txt b/src/requirements.txt index 871567c..3581c50 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,3 @@ google-api-python-client>=2.0 google-auth>=2.0 -requests>=2.31 +requests>=2.33.1 From a9b8ab45df76bc0a71ce507a8f843af190a99ed0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:17:43 +0000 Subject: [PATCH 04/11] Update boto3 requirement from >=1.34 to >=1.43.2 in /listener Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version. - [Release notes](https://github.com/boto/boto3/releases) - [Commits](https://github.com/boto/boto3/compare/1.34.0...1.43.2) --- updated-dependencies: - dependency-name: boto3 dependency-version: 1.43.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- listener/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/listener/requirements.txt b/listener/requirements.txt index 5e1612b..6dd9044 100644 --- a/listener/requirements.txt +++ b/listener/requirements.txt @@ -1,3 +1,3 @@ slack-bolt>=1.18 slack-sdk>=3.27 -boto3>=1.34 +boto3>=1.43.2 From 0bf5e98df373eff33cc99d8f3a64d6db3d85299b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:24:09 +0000 Subject: [PATCH 05/11] Update google-auth requirement from >=2.0 to >=2.50.0 in /src Updates the requirements on [google-auth](https://github.com/googleapis/google-auth-library-python) to permit the latest version. - [Release notes](https://github.com/googleapis/google-auth-library-python/releases) - [Changelog](https://github.com/googleapis/google-auth-library-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-auth-library-python/commits) --- updated-dependencies: - dependency-name: google-auth dependency-version: 2.50.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/requirements.txt b/src/requirements.txt index 44f2559..2155514 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,3 @@ google-api-python-client>=2.195.0 -google-auth>=2.0 +google-auth>=2.50.0 requests>=2.33.1 From 115a2d38dc535ea9c5b8330520aa17b36d7e1794 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 21:24:17 +0000 Subject: [PATCH 06/11] Update slack-sdk requirement from >=3.27 to >=3.41.0 in /listener Updates the requirements on [slack-sdk](https://github.com/slackapi/python-slack-sdk) to permit the latest version. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.27.0...v3.41.0) --- updated-dependencies: - dependency-name: slack-sdk dependency-version: 3.41.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- listener/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/listener/requirements.txt b/listener/requirements.txt index 794240a..aa0b385 100644 --- a/listener/requirements.txt +++ b/listener/requirements.txt @@ -1,3 +1,3 @@ slack-bolt>=1.28.0 -slack-sdk>=3.27 +slack-sdk>=3.41.0 boto3>=1.43.2 From 71113fd547ebb4546406d2d5e068ea71b50e1eae Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sat, 2 May 2026 17:27:00 -0400 Subject: [PATCH 07/11] Auto-assign Dependabot PRs to amoussa1229 --- .github/dependabot.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12867a1..9d9a10a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,17 @@ updates: directory: "/" schedule: interval: "weekly" + assignees: + - "amoussa1229" - package-ecosystem: "pip" directory: "/src" schedule: interval: "weekly" + assignees: + - "amoussa1229" - package-ecosystem: "pip" directory: "/listener" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" + assignees: + - "amoussa1229" \ No newline at end of file From 5abb51807fc4636b9aa7640253795e1a53461320 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 5 May 2026 10:45:14 -0400 Subject: [PATCH 08/11] Add DM fixes, digest stats, tasks/reminders, calendar, and CI/CD pipeline (#15) * 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 --- README.md | 34 +++- buildspec.yml | 11 ++ lib/constructs/email-pipeline.ts | 41 +++- listener/app.py | 4 +- pipeline.yaml | 172 ++++++++++++++++ scripts/get_gmail_token.py | 5 +- src/conversation/app.py | 12 +- src/conversation/tools.py | 328 ++++++++++++++++++++++++++++++- src/reminder/__init__.py | 0 src/reminder/app.py | 25 +++ src/requirements.txt | 1 + src/shared/calendar.py | 72 +++++++ src/shared/dynamo.py | 60 ++++++ src/shared/gmail.py | 5 +- 14 files changed, 751 insertions(+), 19 deletions(-) create mode 100644 buildspec.yml create mode 100644 pipeline.yaml create mode 100644 src/reminder/__init__.py create mode 100644 src/reminder/app.py create mode 100644 src/shared/calendar.py diff --git a/README.md b/README.md index c8da106..9ed6d01 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Exec Aide (Lauren) -Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifies emails by urgency using Claude Haiku (Bedrock), delivers Slack alerts and daily digests, and supports two-way conversation via Slack DM and @mentions in channels. +Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifies emails by urgency using Claude Haiku (Bedrock), delivers Slack alerts and daily digests, manages tasks and reminders, integrates with Google Calendar, and supports two-way conversation via Slack DM and @mentions in channels. ## Architecture @@ -8,17 +8,21 @@ Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifie EventBridge (15 min) → fetch-classify Lambda → Gmail API → Bedrock Haiku → DynamoDB + Slack DM EventBridge Scheduler (5 PM ET M-F) → daily-digest Lambda → DynamoDB + Gmail API → Slack DM Fargate (Socket Mode) → Slack listener → conversation Lambda → Bedrock Sonnet + tools → Slack reply +EventBridge Scheduler (one-shot) → reminder Lambda → Slack DM ``` **Stack:** CDK (TypeScript), stack name `exec-aide`, region us-east-1 +**CI/CD:** CodePipeline + CodeBuild — auto-deploys on push to `main`. Pipeline stack: `exec-aide-pipeline`. + ### Services - **exec-aide-fetch-classify** (Lambda): Polls Gmail via History API every 15 minutes, classifies each new message via Bedrock Haiku, filters out marketing, sends immediate Slack DM for HIGH priority, tracks thread state for unanswered detection. - **exec-aide-daily-digest** (Lambda): Runs weekdays at 5 PM ET (DST-aware). Summarizes high-priority items, bypassed work orders, and unanswered threads older than 24h. -- **exec-aide-conversation** (Lambda): Bedrock Sonnet-powered conversational assistant. Handles multi-turn conversations with tool use for inbox queries, Gmail search, coordination threads, and on-demand digest triggers. Channel-aware: declines private info in public channels. Invoked asynchronously by the listener. +- **exec-aide-conversation** (Lambda): Bedrock Sonnet-powered conversational assistant. Handles multi-turn conversations with tool use for inbox queries, Gmail search, coordination threads, calendar management, tasks, reminders, and on-demand digest triggers. Channel-aware: declines private info in public channels. Invoked asynchronously by the listener. +- **exec-aide-reminder** (Lambda): Receives one-shot EventBridge Scheduler invocations and sends reminder text as a Slack DM to Adam. - **exec-aide-listener** (Fargate): Socket Mode Slack bot. Handles DMs (with "Thinking..." placeholder UX), @mentions in channels (flat or threaded replies), and coordination thread routing. Runs in a dedicated VPC (10.30.0.0/16) on ARM64. -- **DynamoDB `exec-aide`**: Single-table design (MSG#, THD#, META#, CONV#, COORD# prefixes). GSI `by-date` for daily digest queries. 90-day TTL (7-day for COORD). +- **DynamoDB `exec-aide`**: Single-table design (MSG#, THD#, META#, CONV#, COORD#, TASK# prefixes). GSI `by-date` for daily digest queries. 90-day TTL (7-day for COORD). ### Conversation Tools @@ -35,10 +39,18 @@ The conversation Lambda has access to these tools: | `trigger_daily_digest` | Send a digest on demand | | `search_inbox` | Full Gmail search with query syntax | | `coordinate_with_user` | Start a coordination thread with someone in a channel | +| `create_task` | Add a task to Adam's to-do list | +| `list_tasks` | Show open tasks | +| `complete_task` | Mark a task as done | +| `delete_task` | Remove a task | +| `create_reminder` | Schedule a one-shot reminder DM at a specific time | +| `list_calendar_events` | List upcoming Google Calendar events | +| `create_calendar_event` | Create a new calendar event | +| `check_availability` | Check free/busy status for a time range | ### CDK Constructs -- `lib/constructs/email-pipeline.ts` — DynamoDB table, all three Lambda functions, EventBridge schedules +- `lib/constructs/email-pipeline.ts` — DynamoDB table, all four Lambda functions (fetch-classify, daily-digest, conversation, reminder), EventBridge schedules - `lib/constructs/socket-mode.ts` — VPC, ECS cluster, Fargate service, ECR repo (image built automatically via `ContainerImage.fromAsset()`) ## Classification Rules @@ -55,19 +67,21 @@ The conversation Lambda has access to these tools: ## Prerequisites - AWS CDK CLI (`npm install -g aws-cdk`) -- Node.js 18+, Python 3.12 +- Node.js 22+, Python 3.12 - Docker (for Lambda bundling and Fargate image builds) -- Gmail API enabled in Google Cloud Console (project: `exec-aide`) +- Gmail API enabled in Google Cloud Console (project ID: `332395266465`) +- Google Calendar API enabled in Google Cloud Console (same project) - Slack app "Exec Aide" with Socket Mode enabled ## One-Time Setup -### 1. Gmail OAuth +### 1. Google OAuth (Gmail + Calendar) ```bash pip install google-auth-oauthlib -python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.json -# Authorize as adam@seahavenind.com, copy output to Secrets Manager +python scripts/get_gmail_token.py --client-secrets-file ~/.ssh/client_secret_332395266465-mn83qrihoq45njpaiu448rsml1v02f5r.apps.googleusercontent.com.json +# Authorize as adam@seahavenind.com (grants gmail.readonly + calendar scopes) +# Copy output JSON to Secrets Manager ``` ### 2. Secrets Manager @@ -90,6 +104,8 @@ python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.js ## Deploy +Pushes to `main` trigger the CI/CD pipeline automatically. For manual deployment: + ```bash npm install cdk deploy diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 0000000..cd2f3aa --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,11 @@ +version: 0.2 + +phases: + install: + runtime-versions: + nodejs: 22 + commands: + - npm ci + build: + commands: + - npx cdk deploy --require-approval never diff --git a/lib/constructs/email-pipeline.ts b/lib/constructs/email-pipeline.ts index cb8fd01..b25ad66 100644 --- a/lib/constructs/email-pipeline.ts +++ b/lib/constructs/email-pipeline.ts @@ -150,6 +150,31 @@ export class EmailPipelineConstruct extends Construct { sourceArn: `arn:aws:scheduler:${region}:${account}:schedule/default/${schedule.name}`, }); + // ── Reminder Lambda ────────────────────────────────────── + + const reminder = new PythonFunction(this, 'Reminder', { + functionName: 'exec-aide-reminder', + entry: path.join(__dirname, '../../src'), + index: 'reminder/app.py', + handler: 'lambda_handler', + runtime: lambda.Runtime.PYTHON_3_12, + architecture: lambda.Architecture.ARM_64, + memorySize: 128, + timeout: cdk.Duration.seconds(30), + environment: lambdaEnv, + logRetention: logs.RetentionDays.TWO_MONTHS, + }); + + this.table.grantReadData(reminder); + reminder.addToRolePolicy(secretsReadPolicy); + reminder.addToRolePolicy(ssmPolicy); + + const reminderSchedulerRole = new iam.Role(this, 'ReminderSchedulerRole', { + roleName: 'exec-aide-reminder-scheduler', + assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'), + }); + reminder.grantInvoke(reminderSchedulerRole); + // ── Conversation Lambda ─────────────────────────────────── const conversation = new PythonFunction(this, 'Conversation', { @@ -161,7 +186,11 @@ export class EmailPipelineConstruct extends Construct { architecture: lambda.Architecture.ARM_64, memorySize: 512, timeout: cdk.Duration.seconds(180), - environment: lambdaEnv, + environment: { + ...lambdaEnv, + REMINDER_FN_ARN: reminder.functionArn, + REMINDER_SCHEDULER_ROLE_ARN: reminderSchedulerRole.roleArn, + }, logRetention: logs.RetentionDays.TWO_MONTHS, }); @@ -176,6 +205,16 @@ export class EmailPipelineConstruct extends Construct { `arn:aws:bedrock:${region}:${account}:inference-profile/us.anthropic.*`, ], })); + conversation.addToRolePolicy(new iam.PolicyStatement({ + actions: ['scheduler:CreateSchedule', 'scheduler:DeleteSchedule'], + resources: [ + `arn:aws:scheduler:${region}:${account}:schedule/default/exec-aide-reminder-*`, + ], + })); + conversation.addToRolePolicy(new iam.PolicyStatement({ + actions: ['iam:PassRole'], + resources: [reminderSchedulerRole.roleArn], + })); dailyDigest.grantInvoke(conversation); this.conversationFn = conversation; diff --git a/listener/app.py b/listener/app.py index cd0ba43..c5ae779 100644 --- a/listener/app.py +++ b/listener/app.py @@ -64,7 +64,7 @@ def _get_coordination(channel, thread_ts): def _dispatch_conversation(text, thread_ts, channel, say, is_dm): placeholder_ts = None if is_dm: - result = say(text="Thinking...", thread_ts=thread_ts) + result = say(text="Thinking...") placeholder_ts = result["ts"] lambda_client.invoke( @@ -143,7 +143,7 @@ def handle_message(event, say): if not text: return - thread_ts = event.get("thread_ts") or event["ts"] + thread_ts = None _dispatch_conversation(text, thread_ts, event["channel"], say, is_dm=True) diff --git a/pipeline.yaml b/pipeline.yaml new file mode 100644 index 0000000..29f19bf --- /dev/null +++ b/pipeline.yaml @@ -0,0 +1,172 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: CI/CD pipeline — CodePipeline + CodeBuild for CDK deployments + +Parameters: + GitHubOwner: + Type: String + Default: Sea-Haven-Industries + GitHubRepo: + Type: String + Default: exec-aide + GitHubBranch: + Type: String + Default: main + ConnectionArn: + Type: String + Description: CodeConnections ARN for GitHub + Default: arn:aws:codeconnections:us-east-1:328440206208:connection/52bc9d0e-1088-43bc-9394-2c513ed10bc6 + +Resources: + ArtifactBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: exec-aide-pipeline-artifacts + LifecycleConfiguration: + Rules: + - Id: expire-artifacts + Status: Enabled + ExpirationInDays: 30 + Tags: + - Key: Purpose + Value: pipeline-artifacts + - Key: ManagedBy + Value: !Ref AWS::StackName + + CodeBuildRole: + Type: AWS::IAM::Role + Properties: + RoleName: exec-aide-codebuild + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: codebuild-permissions + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/exec-aide-build*" + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:GetBucketLocation + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - Effect: Allow + Action: sts:AssumeRole + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}" + + CodeBuildProject: + Type: AWS::CodeBuild::Project + Properties: + Name: exec-aide-build + Description: Build and deploy exec-aide CDK stack + ServiceRole: !GetAtt CodeBuildRole.Arn + Artifacts: + Type: CODEPIPELINE + Environment: + Type: ARM_CONTAINER + ComputeType: BUILD_GENERAL1_SMALL + Image: aws/codebuild/amazonlinux-aarch64-standard:3.0 + EnvironmentVariables: [] + PrivilegedMode: false + Source: + Type: CODEPIPELINE + BuildSpec: buildspec.yml + TimeoutInMinutes: 10 + + PipelineRole: + Type: AWS::IAM::Role + Properties: + RoleName: exec-aide-pipeline + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: codepipeline.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: pipeline-permissions + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - codeconnections:UseConnection + Resource: !Ref ConnectionArn + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:GetBucketLocation + Resource: + - !GetAtt ArtifactBucket.Arn + - !Sub "${ArtifactBucket.Arn}/*" + - Effect: Allow + Action: + - codebuild:StartBuild + - codebuild:BatchGetBuilds + Resource: !GetAtt CodeBuildProject.Arn + + Pipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + Name: exec-aide-pipeline + RoleArn: !GetAtt PipelineRole.Arn + ArtifactStore: + Type: S3 + Location: !Ref ArtifactBucket + Stages: + - Name: Source + Actions: + - Name: GitHub + ActionTypeId: + Category: Source + Owner: AWS + Provider: CodeStarSourceConnection + Version: "1" + Configuration: + ConnectionArn: !Ref ConnectionArn + FullRepositoryId: !Sub "${GitHubOwner}/${GitHubRepo}" + BranchName: !Ref GitHubBranch + DetectChanges: true + OutputArtifacts: + - Name: SourceOutput + + - Name: Build + Actions: + - Name: CDKDeploy + ActionTypeId: + Category: Build + Owner: AWS + Provider: CodeBuild + Version: "1" + Configuration: + ProjectName: !Ref CodeBuildProject + InputArtifacts: + - Name: SourceOutput + OutputArtifacts: + - Name: BuildOutput + +Outputs: + PipelineName: + Value: !Ref Pipeline + PipelineUrl: + Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view" + ArtifactBucketName: + Value: !Ref ArtifactBucket diff --git a/scripts/get_gmail_token.py b/scripts/get_gmail_token.py index 46b5c5d..ed079a3 100644 --- a/scripts/get_gmail_token.py +++ b/scripts/get_gmail_token.py @@ -13,7 +13,10 @@ import json from google_auth_oauthlib.flow import InstalledAppFlow -SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/calendar", +] def main(): diff --git a/src/conversation/app.py b/src/conversation/app.py index 798ca12..bc43fce 100644 --- a/src/conversation/app.py +++ b/src/conversation/app.py @@ -37,8 +37,16 @@ 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"). -If Adam asks about something outside your current capabilities (calendar, \ -tasks, etc.), acknowledge it and let him know it's not available yet. +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. diff --git a/src/conversation/tools.py b/src/conversation/tools.py index d98157c..b3cdf85 100644 --- a/src/conversation/tools.py +++ b/src/conversation/tools.py @@ -7,6 +7,7 @@ import boto3 from shared.dynamo import ( get_todays_messages, get_unanswered_threads, save_coordination, + create_task, get_tasks, update_task_status, delete_task, ) from shared.gmail import get_authenticated_service, fetch_thread from shared.secrets import get_config, get_slack_token @@ -183,6 +184,185 @@ TOOLS = [ }, } }, + { + "toolSpec": { + "name": "create_task", + "description": "Create a new task/to-do item for Adam.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Task description.", + }, + "due_date": { + "type": "string", + "description": "Optional due date in YYYY-MM-DD format.", + }, + }, + "required": ["title"], + } + }, + } + }, + { + "toolSpec": { + "name": "list_tasks", + "description": "List Adam's tasks. Can filter by status (open or completed).", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by status: 'open' or 'completed'. Omit for all tasks.", + }, + }, + } + }, + } + }, + { + "toolSpec": { + "name": "complete_task", + "description": "Mark a task as completed.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID (ULID) to mark as completed.", + }, + }, + "required": ["task_id"], + } + }, + } + }, + { + "toolSpec": { + "name": "delete_task", + "description": "Delete a task permanently.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID (ULID) to delete.", + }, + }, + "required": ["task_id"], + } + }, + } + }, + { + "toolSpec": { + "name": "create_reminder", + "description": "Set a reminder that will send Adam a Slack DM at a specific time.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The reminder message text.", + }, + "remind_at": { + "type": "string", + "description": "When to send the reminder in ISO 8601 format (e.g., 2026-05-05T09:00:00). Timezone is America/New_York.", + }, + }, + "required": ["text", "remind_at"], + } + }, + } + }, + { + "toolSpec": { + "name": "get_calendar_events", + "description": "Get Adam's upcoming calendar events for a date range.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "Start date in YYYY-MM-DD format. Defaults to today.", + }, + "end_date": { + "type": "string", + "description": "End date in YYYY-MM-DD format. Defaults to start_date + 1 day.", + }, + "max_results": { + "type": "integer", + "description": "Maximum events to return. Defaults to 10.", + }, + }, + } + }, + } + }, + { + "toolSpec": { + "name": "create_calendar_event", + "description": "Create a new event on Adam's Google Calendar. Always confirm details with Adam before calling this.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Event title.", + }, + "start_time": { + "type": "string", + "description": "Start time in ISO 8601 (e.g., 2026-05-05T10:00:00).", + }, + "end_time": { + "type": "string", + "description": "End time in ISO 8601 (e.g., 2026-05-05T11:00:00).", + }, + "attendees": { + "type": "array", + "items": {"type": "string"}, + "description": "Email addresses of attendees.", + }, + "description": { + "type": "string", + "description": "Event description or notes.", + }, + }, + "required": ["summary", "start_time", "end_time"], + } + }, + } + }, + { + "toolSpec": { + "name": "check_availability", + "description": "Check Adam's calendar availability (free/busy) for a time range.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "start_time": { + "type": "string", + "description": "Range start in ISO 8601 format.", + }, + "end_time": { + "type": "string", + "description": "Range end in ISO 8601 format.", + }, + }, + "required": ["start_time", "end_time"], + } + }, + } + }, ] @@ -222,6 +402,14 @@ def dispatch_tool(tool_name, tool_input): "trigger_daily_digest": _handle_trigger_digest, "search_inbox": _handle_search_inbox, "coordinate_with_user": _handle_coordinate, + "create_task": _handle_create_task, + "list_tasks": _handle_list_tasks, + "complete_task": _handle_complete_task, + "delete_task": _handle_delete_task, + "create_reminder": _handle_create_reminder, + "get_calendar_events": _handle_get_calendar_events, + "create_calendar_event": _handle_create_calendar_event, + "check_availability": _handle_check_availability, } handler = handlers.get(tool_name) if not handler: @@ -328,11 +516,19 @@ def _handle_thread_detail(inputs): def _handle_trigger_digest(inputs): - _get_lambda_client().invoke( + resp = _get_lambda_client().invoke( FunctionName="exec-aide-daily-digest", - InvocationType="Event", + InvocationType="RequestResponse", ) - return {"status": "triggered", "message": "Daily digest has been triggered and will arrive shortly."} + payload = json.loads(resp["Payload"].read()) + return { + "status": "sent", + "high_priority": payload.get("high", 0), + "bypassed_work_orders": payload.get("bypassed", 0), + "unanswered_threads": payload.get("unanswered", 0), + "normal": payload.get("normal", 0), + "low": payload.get("low", 0), + } def _handle_search_inbox(inputs): @@ -386,3 +582,129 @@ def _handle_coordinate(inputs): "status": "coordinating", "message": "Thread started. I'll follow up when they respond.", } + + +# ── Task & Reminder Handlers ────────────────────────────────── + + +def _handle_create_task(inputs): + task_id = create_task(inputs["title"], inputs.get("due_date")) + return {"task_id": task_id, "status": "created", "title": inputs["title"]} + + +def _handle_list_tasks(inputs): + status_filter = inputs.get("status") + tasks = get_tasks(status_filter) + return { + "count": len(tasks), + "tasks": [ + { + "task_id": t["pk"].replace("TASK#", ""), + "title": t["title"], + "due_date": t.get("due_date", ""), + "status": t["status"], + "created_at": t.get("created_at", ""), + } + for t in tasks + ], + } + + +def _handle_complete_task(inputs): + update_task_status(inputs["task_id"], "completed") + return {"task_id": inputs["task_id"], "status": "completed"} + + +def _handle_delete_task(inputs): + delete_task(inputs["task_id"]) + return {"task_id": inputs["task_id"], "status": "deleted"} + + +def _handle_create_reminder(inputs): + import ulid as _ulid + + scheduler = boto3.client("scheduler") + schedule_name = f"exec-aide-reminder-{_ulid.new()}" + remind_at = inputs["remind_at"] + + scheduler.create_schedule( + Name=schedule_name, + ScheduleExpression=f"at({remind_at})", + ScheduleExpressionTimezone="America/New_York", + FlexibleTimeWindow={"Mode": "OFF"}, + Target={ + "Arn": os.environ["REMINDER_FN_ARN"], + "RoleArn": os.environ["REMINDER_SCHEDULER_ROLE_ARN"], + "Input": json.dumps({"text": inputs["text"]}), + }, + ActionAfterCompletion="DELETE", + ) + return {"status": "scheduled", "remind_at": remind_at, "text": inputs["text"]} + + +# ── Calendar Handlers ────────────────────────────────────────── + + +def _handle_get_calendar_events(inputs): + from datetime import timedelta + from shared.calendar import get_calendar_service, list_events + + start_date = inputs.get("start_date") or _today() + end_date = inputs.get("end_date") + if not end_date: + d = datetime.strptime(start_date, "%Y-%m-%d") + end_date = (d + timedelta(days=1)).strftime("%Y-%m-%d") + + time_min = f"{start_date}T00:00:00-04:00" + time_max = f"{end_date}T23:59:59-04:00" + max_results = inputs.get("max_results", 10) + + service = get_calendar_service() + events = list_events(service, time_min, time_max, max_results) + + return { + "count": len(events), + "events": [ + { + "id": e.get("id", ""), + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime") or e.get("start", {}).get("date", ""), + "end": e.get("end", {}).get("dateTime") or e.get("end", {}).get("date", ""), + "attendees": [a.get("email", "") for a in e.get("attendees", [])], + "location": e.get("location", ""), + "status": e.get("status", ""), + } + for e in events + ], + } + + +def _handle_create_calendar_event(inputs): + from shared.calendar import get_calendar_service, create_event + + service = get_calendar_service() + event = create_event( + service, + summary=inputs["summary"], + start={"dateTime": inputs["start_time"], "timeZone": "America/New_York"}, + end={"dateTime": inputs["end_time"], "timeZone": "America/New_York"}, + attendees=inputs.get("attendees"), + description=inputs.get("description"), + ) + return { + "status": "created", + "event_id": event["id"], + "summary": event.get("summary", ""), + "html_link": event.get("htmlLink", ""), + } + + +def _handle_check_availability(inputs): + from shared.calendar import get_calendar_service, check_availability + + service = get_calendar_service() + busy = check_availability(service, inputs["start_time"], inputs["end_time"]) + return { + "busy_slots": busy, + "is_free": len(busy) == 0, + } diff --git a/src/reminder/__init__.py b/src/reminder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/reminder/app.py b/src/reminder/app.py new file mode 100644 index 0000000..974d5e9 --- /dev/null +++ b/src/reminder/app.py @@ -0,0 +1,25 @@ +import logging + +from shared.secrets import get_config, get_slack_token +from shared.slack import post_message + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event, context): + text = event.get("text", "Reminder (no details provided)") + config = get_config() + token = get_slack_token() + + import requests + resp = requests.post( + "https://slack.com/api/conversations.open", + headers={"Authorization": f"Bearer {token}"}, + json={"users": config["adam_slack_user_id"]}, + ) + resp.raise_for_status() + channel = resp.json()["channel"]["id"] + + post_message(channel, f":bell: *Reminder:* {text}", token) + logger.info("Reminder sent: %s", text) diff --git a/src/requirements.txt b/src/requirements.txt index 2155514..49e3779 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,4 @@ google-api-python-client>=2.195.0 google-auth>=2.50.0 +python-ulid>=2.0 requests>=2.33.1 diff --git a/src/shared/calendar.py b/src/shared/calendar.py new file mode 100644 index 0000000..029b78a --- /dev/null +++ b/src/shared/calendar.py @@ -0,0 +1,72 @@ +import logging + +import google.auth.transport.requests +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +from shared.secrets import get_gmail_oauth, save_gmail_tokens + +logger = logging.getLogger(__name__) + +_service = None + +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/calendar", +] + + +def get_calendar_service(): + global _service + oauth = get_gmail_oauth() + creds = Credentials( + token=oauth.get("access_token"), + refresh_token=oauth["refresh_token"], + token_uri="https://oauth2.googleapis.com/token", + client_id=oauth["client_id"], + client_secret=oauth["client_secret"], + scopes=SCOPES, + ) + if not creds.valid: + creds.refresh(google.auth.transport.requests.Request()) + save_gmail_tokens( + creds.token, + creds.expiry.isoformat() if creds.expiry else "", + ) + _service = build("calendar", "v3", credentials=creds, cache_discovery=False) + return _service + + +def list_events(service, time_min, time_max, max_results=10): + result = service.events().list( + calendarId="primary", + timeMin=time_min, + timeMax=time_max, + maxResults=max_results, + singleEvents=True, + orderBy="startTime", + ).execute() + return result.get("items", []) + + +def create_event(service, summary, start, end, attendees=None, description=None): + body = { + "summary": summary, + "start": start, + "end": end, + } + if description: + body["description"] = description + if attendees: + body["attendees"] = [{"email": a} for a in attendees] + return service.events().insert(calendarId="primary", body=body).execute() + + +def check_availability(service, time_min, time_max): + body = { + "timeMin": time_min, + "timeMax": time_max, + "items": [{"id": "primary"}], + } + result = service.freebusy().query(body=body).execute() + return result["calendars"]["primary"]["busy"] diff --git a/src/shared/dynamo.py b/src/shared/dynamo.py index d1e3307..8af3b20 100644 --- a/src/shared/dynamo.py +++ b/src/shared/dynamo.py @@ -3,6 +3,7 @@ import time from datetime import datetime, timezone import boto3 +import ulid from botocore.exceptions import ClientError _table = None @@ -205,3 +206,62 @@ def get_unanswered_threads(threshold_hours): item for item in items if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff ] + + +# ── Tasks ────────────────────────────────────────────────────── + + +def create_task(title, due_date=None): + task_id = str(ulid.new()) + _get_table().put_item(Item={ + "pk": f"TASK#{task_id}", + "sk": "TASK", + "title": title, + "due_date": due_date or "", + "status": "open", + "created_at": datetime.now(timezone.utc).isoformat(), + "completed_at": "", + "ttl": _ttl_90_days(), + }) + return task_id + + +def get_tasks(status_filter=None): + scan_kwargs = { + "FilterExpression": "begins_with(pk, :prefix)", + "ExpressionAttributeValues": {":prefix": "TASK#"}, + } + if status_filter: + scan_kwargs["FilterExpression"] += " AND #s = :status" + scan_kwargs["ExpressionAttributeNames"] = {"#s": "status"} + scan_kwargs["ExpressionAttributeValues"][":status"] = status_filter + + items = [] + resp = _get_table().scan(**scan_kwargs) + items.extend(resp.get("Items", [])) + while "LastEvaluatedKey" in resp: + scan_kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"] + resp = _get_table().scan(**scan_kwargs) + items.extend(resp.get("Items", [])) + + items.sort(key=lambda x: x["pk"]) + return items + + +def update_task_status(task_id, status): + update_expr = "SET #s = :status, ttl = :ttl" + expr_values = {":status": status, ":ttl": _ttl_90_days()} + if status == "completed": + update_expr += ", completed_at = :now" + expr_values[":now"] = datetime.now(timezone.utc).isoformat() + + _get_table().update_item( + Key={"pk": f"TASK#{task_id}", "sk": "TASK"}, + UpdateExpression=update_expr, + ExpressionAttributeNames={"#s": "status"}, + ExpressionAttributeValues=expr_values, + ) + + +def delete_task(task_id): + _get_table().delete_item(Key={"pk": f"TASK#{task_id}", "sk": "TASK"}) diff --git a/src/shared/gmail.py b/src/shared/gmail.py index 3084886..39f8c1b 100644 --- a/src/shared/gmail.py +++ b/src/shared/gmail.py @@ -13,7 +13,10 @@ logger = logging.getLogger(__name__) _service = None -SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/calendar", +] def get_authenticated_service(): From bfab515807d96d581ecd777e6ceff81cfbb5accc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 5 May 2026 10:55:52 -0400 Subject: [PATCH 09/11] Fix pipeline: add ECR permissions and enable Docker for Fargate image builds (#16) --- pipeline.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pipeline.yaml b/pipeline.yaml index 29f19bf..56369f0 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -69,6 +69,21 @@ Resources: - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}" - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}" + - Effect: Allow + Action: + - ecr:GetAuthorizationToken + Resource: "*" + - Effect: Allow + Action: + - ecr:DescribeRepositories + - ecr:BatchCheckLayerAvailability + - ecr:GetDownloadUrlForLayer + - ecr:BatchGetImage + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + Resource: !Sub "arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}" CodeBuildProject: Type: AWS::CodeBuild::Project @@ -83,7 +98,7 @@ Resources: ComputeType: BUILD_GENERAL1_SMALL Image: aws/codebuild/amazonlinux-aarch64-standard:3.0 EnvironmentVariables: [] - PrivilegedMode: false + PrivilegedMode: true Source: Type: CODEPIPELINE BuildSpec: buildspec.yml From fc1203aa6a53876b19148a8bb19c608a781afb25 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:46 -0400 Subject: [PATCH 10/11] Fix pipeline: add image-publishing role for CDK container asset deploys (#17) --- pipeline.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pipeline.yaml b/pipeline.yaml index 56369f0..5330191 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -67,6 +67,7 @@ Resources: Resource: - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}" - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-image-publishing-role-${AWS::AccountId}-${AWS::Region}" - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}" - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}" - Effect: Allow From 79b6c254ff89e9ba8941fa0b450cb9c21696b82e Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 6 May 2026 18:11:27 -0400 Subject: [PATCH 11/11] Add Claude Code review workflow (#18) --- .github/workflows/claude-review.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/claude-review.yaml diff --git a/.github/workflows/claude-review.yaml b/.github/workflows/claude-review.yaml new file mode 100644 index 0000000..aefbd96 --- /dev/null +++ b/.github/workflows/claude-review.yaml @@ -0,0 +1,15 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + uses: Sea-Haven-Industries/.github/.github/workflows/claude-code-review.yaml@main + secrets: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}