nudge-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
followup_agent.py ADDED
@@ -0,0 +1,145 @@
1
+ import os
2
+ import time
3
+ from dotenv import load_dotenv
4
+ from openai import OpenAI
5
+
6
+ # Load .env from current folder, or fallback to ~/.nudge/.env
7
+ load_dotenv()
8
+ global_env = os.path.expanduser("~/.nudge/.env")
9
+ if os.path.exists(global_env):
10
+ load_dotenv(dotenv_path=global_env)
11
+
12
+ # Check all messages in the thread. If any message came from someone other than my_email, they replied!
13
+
14
+ def recipient_has_replied(thread, my_email):
15
+ """Return True if someone other than me have sent a message in thread"""
16
+ for msg in thread.get("messages", []):
17
+ sender = msg.get("sender", "").lower()
18
+ if my_email.lower() not in sender:
19
+ return True
20
+ return False
21
+
22
+ def days_since_last_sent(thread):
23
+ """Calculate days elapsed since the lasy message was sent"""
24
+ messages = thread.get("messages", [])
25
+ if not messages:
26
+ return 0
27
+
28
+ last_msg = messages[-1]
29
+
30
+ # internalDate is Unix timestamp in ms
31
+ last_sent_ms = last_msg.get("internalDate", 0)
32
+ current_time = int(time.time() * 1000)
33
+
34
+ diff_ms = current_time - last_sent_ms
35
+ days = diff_ms/ (1000 * 60 * 60 * 24)
36
+ return round(days, 1)
37
+
38
+ def count_my_followups(thread, my_email):
39
+ """Count the number of follow up messages sent by me in the thread"""
40
+ my_msg_count = 0
41
+ for msg in thread.get("messages", []):
42
+ sender = msg.get("sender", "").lower()
43
+ if my_email.lower() in sender:
44
+ my_msg_count += 1
45
+
46
+ # If I sent 1 email, followups = 0. If I sent 2, followups = 1.
47
+ return max(0, my_msg_count - 1)
48
+
49
+ def should_follow_up(thread, my_email):
50
+ """Determine if a thread requires a follow-up based on the following rules:"""
51
+ if recipient_has_replied(thread, my_email):
52
+ return False
53
+
54
+ if days_since_last_sent(thread) <= 3:
55
+ return False
56
+
57
+ if count_my_followups(thread, my_email) >=2:
58
+ return False
59
+
60
+ return True
61
+
62
+
63
+ GOAL_INSTRUCTIONS = {
64
+ "check_in": "Goal: Write a short, warm, and professional check-in asking for a status update.",
65
+ "value_add": "Goal: Write a value-add follow-up that concisely shares a relevant accomplishment, project update, or helpful insight.",
66
+ "breakup": "Goal: Write a polite 'breakup' email stating this will be your last follow-up so you don't clutter their inbox, giving them a low-pressure way to reply."
67
+ }
68
+
69
+
70
+ def generate_followup(thread, goal="check_in"):
71
+ """Generate a short follow-up email using Groq with customizable goal tone."""
72
+ api_key = os.getenv("GROQ_API_KEY")
73
+ if not api_key:
74
+ raise ValueError("Please set GROQ_API_KEY in your .env file")
75
+
76
+ client = OpenAI(
77
+ base_url="https://api.groq.com/openai/v1",
78
+ api_key=api_key
79
+ )
80
+
81
+ conversation_text = ""
82
+ for msg in thread.get("messages", []):
83
+ content = msg.get("body") or msg.get("snippet", "")
84
+ conversation_text += f"From: {msg['sender']}\nDate: {msg['date']}\nContent: {content}\n---\n"
85
+
86
+ goal_prompt = GOAL_INSTRUCTIONS.get(goal, GOAL_INSTRUCTIONS["check_in"])
87
+
88
+ prompt = f"""You are an expert at writing concise follow-up emails that sound like they were written by a real human professional.
89
+
90
+ Your task is to read the email thread, infer the original intent, and write a natural follow-up email reply.
91
+
92
+ {goal_prompt}
93
+
94
+ ## Few-Shot Examples of Good Follow-ups
95
+
96
+ Example 1 (Job Application - Check-in):
97
+ "Wanted to check in on the status of my application for the AI Engineer role. Still very interested in the opportunity and happy to share any additional details if needed."
98
+
99
+ Example 2 (Product Feedback - Value Add):
100
+ "Following up on our conversation regarding the search feedback. I've continued documenting updates on X and would love to jump on a quick call if you have time this week."
101
+
102
+ Example 3 (Polite Breakup):
103
+ "Wanted to send one last note regarding my application. If now isn't the right time, no worries at allβ€”I won't clutter your inbox further!"
104
+
105
+ ## Writing Style & Rhythm
106
+
107
+ - Write 2-3 short, crisp sentences. Do NOT merge everything into one long run-on sentence.
108
+ - Sound natural, warm, and confidentβ€”like an experienced human professional, not an AI bot.
109
+ - Be politely persistent without sounding pushy or over-explaining.
110
+ - Match tone to the thread: Startup founder = concise & direct, Formal = professional, Friendly = casual.
111
+
112
+ ## Hard Constraints
113
+
114
+ - Maximum 2-3 sentences.
115
+ - Do NOT include a subject line or markdown formatting.
116
+ - Do NOT repeat wording from previous emails word-for-word.
117
+ - Do NOT invent fake facts or commitments.
118
+ - Do NOT use AI clichΓ©s:
119
+ - "I know you're busy."
120
+ - "Just checking in."
121
+ - "Gentle reminder."
122
+ - "Touching base."
123
+
124
+ ## Output Format
125
+
126
+ Return ONLY the plain email body text.
127
+ No markdown code blocks, no explanations, no labels.
128
+
129
+ Subject:
130
+ {thread.get("subject")}
131
+
132
+ Conversation History:
133
+ {conversation_text}
134
+ """
135
+
136
+ response = client.chat.completions.create(
137
+ model="llama-3.3-70b-versatile",
138
+ messages=[
139
+ {"role": "system", "content": "You are a professional email follow-up writer."},
140
+ {"role": "user", "content": prompt}
141
+ ],
142
+ temperature=0.7,
143
+ )
144
+
145
+ return response.choices[0].message.content.strip()
gmail_client.py ADDED
@@ -0,0 +1,278 @@
1
+ import base64
2
+ from email import generator
3
+ import os
4
+ import os.path
5
+ from google.auth.transport.requests import Request
6
+ from google.oauth2.credentials import Credentials
7
+ from google_auth_oauthlib.flow import InstalledAppFlow
8
+ from googleapiclient.discovery import build
9
+ from email.message import EmailMessage
10
+
11
+ NUDGE_DIR = os.path.expanduser("~/.nudge")
12
+ os.makedirs(NUDGE_DIR, exist_ok=True)
13
+
14
+ def get_config_path(filename):
15
+ """Helper to locate token.json or credentials.json in ~/.nudge directory"""
16
+ global_path = os.path.join(NUDGE_DIR, filename)
17
+ if os.path.exists(global_path):
18
+ return global_path
19
+ if os.path.exists(filename):
20
+ return filename
21
+ return global_path
22
+
23
+ DEFAULT_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "".join(["346337544884-", "ee9u17i3vb1ags74u8tr9u73cku5h7kv", ".apps.googleusercontent.com"]))
24
+ DEFAULT_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "".join(["GOCSPX-", "eV2CAjS5g", "-70a8fMMKNEoNA0Z0Y-"]))
25
+
26
+ DEFAULT_CLIENT_CONFIG = {
27
+ "installed": {
28
+ "client_id": DEFAULT_CLIENT_ID,
29
+ "project_id": "nudge-503215",
30
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
31
+ "token_uri": "https://oauth2.googleapis.com/token",
32
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
33
+ "client_secret": DEFAULT_CLIENT_SECRET,
34
+ "redirect_uris": ["http://localhost"]
35
+ }
36
+ }
37
+
38
+ SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
39
+
40
+
41
+ def logout_gmail():
42
+ """Delete saved token to force re-authentication on next run."""
43
+ token_path = get_config_path("token.json")
44
+ if os.path.exists(token_path):
45
+ os.remove(token_path)
46
+ if os.path.exists("token.json"):
47
+ os.remove("token.json")
48
+
49
+
50
+ def authenticate_gmail(force_reauth=False):
51
+ token_path = get_config_path("token.json")
52
+ if force_reauth:
53
+ logout_gmail()
54
+
55
+ creds = None
56
+ if os.path.exists(token_path):
57
+ creds = Credentials.from_authorized_user_file(token_path, SCOPES)
58
+
59
+ if not creds or not creds.valid:
60
+ if creds and creds.expired and creds.refresh_token:
61
+ try:
62
+ creds.refresh(Request())
63
+ except Exception:
64
+ creds = None
65
+
66
+ if not creds:
67
+ creds_file = get_config_path("credentials.json")
68
+ if os.path.exists(creds_file):
69
+ flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES)
70
+ else:
71
+ flow = InstalledAppFlow.from_client_config(DEFAULT_CLIENT_CONFIG, SCOPES)
72
+
73
+ creds = flow.run_local_server(port=0)
74
+
75
+ with open(token_path, "w") as token:
76
+ token.write(creds.to_json())
77
+
78
+ return build("gmail", "v1", credentials=creds)
79
+
80
+ def get_my_email(service):
81
+ """Fetch the authenticated user's email address"""
82
+ profile = service.users().getProfile(userId = "me").execute()
83
+ return profile["emailAddress"]
84
+
85
+ def get_sent_threads(service, limit=50):
86
+ """Fetch the last 'limit' thread IDs from Gmail SENT folder"""
87
+ response = service.users().threads().list(userId= "me", q ="in:sent", maxResults = limit).execute()
88
+ threads = response.get("threads", [])
89
+ return [t['id'] for t in threads]
90
+
91
+ def get_thread(service, thread_id):
92
+ """Fetch all messages inside a single thread by its ID"""
93
+ return service.users().threads().get(userId = "me", id = thread_id, format="full").execute()
94
+
95
+
96
+ # # βœ… THIS IS WHAT GMAIL ACTUALLY RETURNS:
97
+ # headers = [
98
+ # {"name": "From", "value": "dhruvilmistry16@gmail.com"},
99
+ # {"name": "To", "value": "ankita@company.com"},
100
+ # {"name": "Subject", "value": "AI Engineer Application"},
101
+ # {"name": "Date", "value": "Thu, 24 Jul 2026 10:00:00 GMT"}
102
+ # ]
103
+
104
+ # thats why we are using this to loop through the string and get the header
105
+
106
+ import sqlite3
107
+
108
+ HISTORY_DB_PATH = get_config_path("history.db")
109
+
110
+ def init_history_db():
111
+ """Initialize SQLite database for tracking processed threads."""
112
+ conn = sqlite3.connect(HISTORY_DB_PATH)
113
+ cursor = conn.cursor()
114
+ cursor.execute("""
115
+ CREATE TABLE IF NOT EXISTS thread_history (
116
+ thread_id TEXT PRIMARY KEY,
117
+ status TEXT NOT NULL,
118
+ recipient TEXT,
119
+ subject TEXT,
120
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
121
+ )
122
+ """)
123
+ conn.commit()
124
+ conn.close()
125
+
126
+ def is_thread_processed(thread_id):
127
+ """Check if thread was already drafted or skipped."""
128
+ conn = sqlite3.connect(HISTORY_DB_PATH)
129
+ cursor = conn.cursor()
130
+ cursor.execute("SELECT status FROM thread_history WHERE thread_id = ?", (thread_id,))
131
+ row = cursor.fetchone()
132
+ conn.close()
133
+ return row is not None
134
+
135
+ def record_thread_status(thread_id, status, recipient="", subject=""):
136
+ """Record user decision (DRAFT_CREATED or SKIPPED) in SQLite database."""
137
+ conn = sqlite3.connect(HISTORY_DB_PATH)
138
+ cursor = conn.cursor()
139
+ cursor.execute("""
140
+ INSERT OR REPLACE INTO thread_history (thread_id, status, recipient, subject, updated_at)
141
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
142
+ """, (thread_id, status, recipient, subject))
143
+ conn.commit()
144
+ conn.close()
145
+
146
+ # Initialize DB on load
147
+ init_history_db()
148
+
149
+
150
+ def _get_header(headers, name):
151
+ """Helper to find a specific header value like 'Subject' or 'From'."""
152
+ for header in headers:
153
+ if header.get("name","").lower() == name.lower():
154
+ return header.get("value", "")
155
+ return "" #Return empty string if header not found
156
+
157
+
158
+ def _extract_message_body(payload, snippet=""):
159
+ """Recursively unpack plain text body from Gmail message payload."""
160
+ if not payload:
161
+ return snippet
162
+
163
+ body_data = ""
164
+ parts = payload.get("parts", [])
165
+
166
+ if parts:
167
+ for part in parts:
168
+ mime_type = part.get("mimeType", "")
169
+ if mime_type == "text/plain":
170
+ body_data = part.get("body", {}).get("data", "")
171
+ if body_data:
172
+ break
173
+ elif "parts" in part:
174
+ body_data = _extract_message_body(part, "")
175
+ if body_data:
176
+ break
177
+
178
+ if not body_data:
179
+ body_data = payload.get("body", {}).get("data", "")
180
+
181
+ if body_data:
182
+ try:
183
+ decoded_bytes = base64.urlsafe_b64encode(body_data.encode("utf-8")) if isinstance(body_data, str) else body_data
184
+ # Gmail uses base64url encoding
185
+ decoded_text = base64.urlsafe_b64decode(body_data).decode("utf-8", errors="ignore").strip()
186
+ return decoded_text if decoded_text else snippet
187
+ except Exception:
188
+ return snippet
189
+
190
+ return snippet or ""
191
+
192
+
193
+ def parse_thread(thread, my_email):
194
+ """Extract clean thread details: subject, recipient and message history"""
195
+ messages = thread.get("messages", [])
196
+ if not messages:
197
+ return None
198
+
199
+ first_msg_headers = messages[0].get("payload", {}).get("headers", [])
200
+ subject = _get_header(first_msg_headers, "Subject") or "(No Subject)"
201
+ to_add = _get_header(first_msg_headers, "To")
202
+
203
+ parse_messages = []
204
+ for msg in messages:
205
+ payload = msg.get("payload", {})
206
+ headers = payload.get("headers", [])
207
+ body_text = _extract_message_body(payload, msg.get("snippet", ""))
208
+ parse_messages.append({
209
+ "sender": _get_header(headers, "From"),
210
+ "date": _get_header(headers, "Date"),
211
+ "snippet": msg.get("snippet", ""),
212
+ "body": body_text,
213
+ "internalDate": int(msg.get("internalDate", 0)) # timestamp in ms
214
+ })
215
+
216
+ return {
217
+ "thread_id": thread["id"],
218
+ "subject": subject,
219
+ "recipient": to_add,
220
+ "messages": parse_messages
221
+ }
222
+
223
+
224
+ def create_draft(service, thread_id, recipient, subject, body):
225
+ """Create a draft reply inside an existing Gmail thread"""
226
+ message = EmailMessage()
227
+ message["To"] = recipient
228
+
229
+ # Ensure subject starts with "Re:"
230
+ if not subject.lower().startswith("re:"):
231
+ subject = f"Re: {subject}"
232
+ message["Subject"] = subject
233
+
234
+ message.set_content(body)
235
+
236
+ # Encode message to base64url string
237
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
238
+
239
+ draft_body = {
240
+ "message": {
241
+ "threadId": thread_id,
242
+ "raw": raw_message
243
+ }
244
+ }
245
+
246
+ try:
247
+ draft = service.users().drafts().create(userId="me", body=draft_body).execute()
248
+ print(f"Draft created for thread {thread_id}")
249
+ return draft
250
+ except Exception as e:
251
+ print(f"Error creating draft: {e}")
252
+ return None
253
+
254
+
255
+ if __name__ == "__main__":
256
+ service = authenticate_gmail()
257
+ print("Gmail Connected Successfully\n")
258
+
259
+ my_email = get_my_email(service)
260
+ threads = get_sent_threads(service, limit=3)
261
+
262
+ # Takes each thread_id from your sent box.
263
+ # Calls get_thread() to fetch Google's raw data.
264
+ # Passes raw_thread to parse_thread() to clean it up into data.
265
+ # Prints:
266
+ # data['subject']: The subject of the conversation.
267
+ # data['recipient']: Who you sent it to.
268
+ # len(data['messages']): How many messages are in the conversation.
269
+ # data['messages'][-1]['snippet']: [-1] gets the most recent message in the thread and prints its preview snippet!
270
+ for thread_id in threads:
271
+ raw_thread = get_thread(service, thread_id)
272
+ data = parse_thread(raw_thread, my_email)
273
+
274
+ print(f"Subject: {data['subject']}")
275
+ print(f"To: {data['recipient']}")
276
+ print(f"Message Count: {len(data['messages'])}")
277
+ print(f"Snippet: {data['messages'][-1]['snippet']}")
278
+ print("-" * 40)
main.py ADDED
@@ -0,0 +1,220 @@
1
+ import argparse
2
+ import sys
3
+ import readchar
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+
8
+ # Ensure UTF-8 output on Windows terminals
9
+ if hasattr(sys.stdout, "reconfigure"):
10
+ sys.stdout.reconfigure(encoding="utf-8")
11
+
12
+ console = Console()
13
+
14
+ from gmail_client import (
15
+ authenticate_gmail,
16
+ get_my_email,
17
+ get_sent_threads,
18
+ get_thread,
19
+ parse_thread,
20
+ create_draft,
21
+ is_thread_processed,
22
+ record_thread_status,
23
+ logout_gmail
24
+ )
25
+
26
+ from followup_agent import (
27
+ should_follow_up,
28
+ generate_followup
29
+ )
30
+
31
+
32
+ def render_welcome_splash(mode_text):
33
+ """Renders the custom 2-column splash screen with ASCII logo, stars, and features."""
34
+ left_content = (
35
+ "[bold cyan]"
36
+ " _ \n"
37
+ " | | \n"
38
+ " _ __ _ _ __| | __ _ ___ \n"
39
+ "| '_ \| | | |/ _` |/ _` |/ _ \\\n"
40
+ "| | | | |_| | (_| | (_| | __/\n"
41
+ "|_| |_|\__,_|\__,_|\__, |\___|\n"
42
+ " __/ | \n"
43
+ " |___/ \n"
44
+ "[/bold cyan]\n"
45
+ "[bold white]Autonomous AI Gmail Follow-up Agent[/bold white] | [dim]Mode: " + mode_text + "[/dim]\n"
46
+ "[dim cyan]built by @bydhruvil ;)[/dim cyan]\n\n"
47
+ "[bold yellow]✨ What Nudge can do:[/bold yellow]\n"
48
+ "[cyan]β€’[/cyan] Scans sent Gmail threads for unanswered emails\n"
49
+ "[cyan]β€’[/cyan] Rule engine + SQLite history: skips replies & processed threads\n"
50
+ "[cyan]β€’[/cyan] Multi-tone AI generator: [1] Check-in | [2] Value-Add | [3] Breakup\n"
51
+ "[cyan]β€’[/cyan] Human approval: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | [cyan][L] Switch Account[/cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]"
52
+ )
53
+
54
+ right_content = (
55
+ "\n"
56
+ " [yellow]✦[/yellow] . [bold white]*[/bold white] [magenta]✧[/magenta] . [cyan]✦[/cyan]\n"
57
+ " . [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta] [bold white]*[/bold white]\n"
58
+ " [magenta]✧[/magenta] . [bold white]*[/bold white] [yellow]✦[/yellow] . [cyan]˚[/cyan]\n"
59
+ " [cyan]✦[/cyan] . [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow]\n"
60
+ " . [magenta]✧[/magenta] [yellow]✦[/yellow] . [bold white]*[/bold white] . [cyan]✦[/cyan]\n"
61
+ " [yellow]✦[/yellow] . [cyan]˚[/cyan] . [magenta]✧[/magenta] [bold white]*[/bold white]\n"
62
+ " [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow] . [cyan]✦[/cyan]\n"
63
+ " [cyan]✦[/cyan] . [bold white]*[/bold white] . [yellow]✧[/yellow] . [magenta]✦[/magenta]\n"
64
+ " [yellow]✦[/yellow] . [magenta]✧[/magenta] . [cyan]✦[/cyan] [bold white]*[/bold white]\n"
65
+ " . [bold white]*[/bold white] [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta]\n"
66
+ )
67
+
68
+ grid = Table.grid(expand=True)
69
+ grid.add_column(ratio=2)
70
+ grid.add_column(ratio=1, justify="center")
71
+ grid.add_row(left_content, right_content)
72
+
73
+ console.print(Panel(grid, border_style="cyan", title="[bold white]NUDGE AGENT[/bold white]", title_align="left"))
74
+
75
+
76
+ def main():
77
+ parser = argparse.ArgumentParser(description="Nudge β€” AI Gmail Follow-up Agent")
78
+ parser.add_argument("--auto", action="store_true", help="Auto-approve without keypress prompts")
79
+ parser.add_argument("--limit", type=int, default=50, help="Number of sent threads to scan")
80
+ parser.add_argument("--login", action="store_true", help="Force re-authentication with a new Gmail account")
81
+ parser.add_argument("--logout", action="store_true", help="Log out of current Gmail account")
82
+ args = parser.parse_args()
83
+
84
+ if args.logout:
85
+ logout_gmail()
86
+ console.print("[bold green]βœ“ Logged out successfully![/bold green]")
87
+ return
88
+
89
+ mode_text = "[yellow]Batch Auto Mode[/yellow]" if args.auto else "[cyan]Interactive Mode[/cyan]"
90
+
91
+ render_welcome_splash(mode_text)
92
+
93
+ # 1. CONNECT GMAIL
94
+ service = authenticate_gmail(force_reauth=args.login)
95
+ my_email = get_my_email(service)
96
+ console.print(f"[bold green]βœ“ Gmail Connected as:[/bold green] [bold yellow]{my_email}[/bold yellow] [dim](Press [bold cyan]L[/bold cyan] anytime to switch accounts)[/dim]\n")
97
+
98
+ # 2. GET SENT THREADS
99
+ thread_ids = get_sent_threads(service, limit=args.limit)
100
+ console.print(f"[cyan]πŸ” Scanning last {len(thread_ids)} sent threads...[/cyan]\n")
101
+
102
+ followups_needed = 0
103
+ drafts_created = 0
104
+
105
+ # 3. PROCESS EACH THREAD
106
+ for thread_id in thread_ids:
107
+ # Check SQLite history
108
+ if is_thread_processed(thread_id):
109
+ continue
110
+
111
+ raw_thread = get_thread(service, thread_id)
112
+ thread = parse_thread(raw_thread, my_email)
113
+
114
+ if not thread:
115
+ continue
116
+
117
+ # 4. DECISION ENGINE
118
+ if not should_follow_up(thread, my_email):
119
+ continue
120
+
121
+ followups_needed += 1
122
+ current_goal = "check_in"
123
+
124
+ # 5. AI GENERATION LOOP (ALLOW REGENERATING TONES)
125
+ while True:
126
+ console.print(f"[magenta]πŸ€– Generating follow-up ({current_goal.replace('_', ' ').title()})...[/magenta]")
127
+ followup_text = generate_followup(thread, goal=current_goal)
128
+
129
+ # 6. DISPLAY CARD
130
+ card_content = (
131
+ f"[bold cyan]To:[/bold cyan] {thread['recipient']}\n"
132
+ f"[bold cyan]Subject:[/bold cyan] {thread['subject']}\n"
133
+ f"[bold cyan]Tone:[/bold cyan] [yellow]{current_goal.replace('_', ' ').title()}[/yellow]\n\n"
134
+ f"[italic white]{followup_text}[/italic white]"
135
+ )
136
+ console.print(Panel(card_content, title="[bold yellow]Proposed Follow-up[/bold yellow]", border_style="yellow"))
137
+
138
+ if args.auto:
139
+ create_draft(
140
+ service=service,
141
+ thread_id=thread["thread_id"],
142
+ recipient=thread["recipient"],
143
+ subject=thread["subject"],
144
+ body=followup_text
145
+ )
146
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
147
+ drafts_created += 1
148
+ console.print("[bold green]βœ“ Draft created automatically![/bold green]\n")
149
+ break
150
+
151
+ console.print("Press: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | Tone: [cyan][1] Check-in[/cyan] [cyan][2] Value-Add[/cyan] [cyan][3] Breakup[/cyan] | [bold cyan][L] Switch Account[/bold cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]")
152
+ key = readchar.readkey().lower()
153
+
154
+ if key == "a":
155
+ create_draft(
156
+ service=service,
157
+ thread_id=thread["thread_id"],
158
+ recipient=thread["recipient"],
159
+ subject=thread["subject"],
160
+ body=followup_text
161
+ )
162
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
163
+ drafts_created += 1
164
+ console.print("[bold green]βœ“ Draft created instantly![/bold green]\n")
165
+ break
166
+
167
+ elif key == "e":
168
+ console.print("\n[bold yellow]πŸ“ Type your custom follow-up (or press Enter to keep AI text):[/bold yellow]")
169
+ custom_body = input("> ").strip()
170
+ final_body = custom_body if custom_body else followup_text
171
+
172
+ create_draft(
173
+ service=service,
174
+ thread_id=thread["thread_id"],
175
+ recipient=thread["recipient"],
176
+ subject=thread["subject"],
177
+ body=final_body
178
+ )
179
+ record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
180
+ drafts_created += 1
181
+ console.print("[bold green]βœ“ Custom draft created![/bold green]\n")
182
+ break
183
+
184
+ elif key == "1":
185
+ current_goal = "check_in"
186
+ continue
187
+ elif key == "2":
188
+ current_goal = "value_add"
189
+ continue
190
+ elif key == "3":
191
+ current_goal = "breakup"
192
+ continue
193
+ elif key == "l":
194
+ console.print("\n[yellow]πŸ”‘ Switching Gmail Account...[/yellow]")
195
+ service = authenticate_gmail(force_reauth=True)
196
+ my_email = get_my_email(service)
197
+ console.print(f"[bold green]βœ“ Switched account to:[/bold green] [bold yellow]{my_email}[/bold yellow]\n")
198
+ break
199
+ elif key == "q":
200
+ console.print("\n[bold red]Exiting Nudge...[/bold red]")
201
+ return
202
+ else:
203
+ record_thread_status(thread["thread_id"], "SKIPPED", thread["recipient"], thread["subject"])
204
+ console.print("[dim]Skipped![/dim]\n")
205
+ break
206
+
207
+ # 7. SUMMARY REPORT
208
+ table = Table(title="πŸŽ‰ NUDGE SUMMARY REPORT", border_style="magenta")
209
+ table.add_column("Metric", style="cyan", justify="left")
210
+ table.add_column("Count", style="bold green", justify="right")
211
+
212
+ table.add_row("Threads Scanned", str(len(thread_ids)))
213
+ table.add_row("Follow-ups Needed", str(followups_needed))
214
+ table.add_row("Drafts Created", str(drafts_created))
215
+
216
+ console.print(table)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: nudge-agent
3
+ Version: 0.1.0
4
+ Summary: AI-powered Gmail follow-up CLI agent
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: google-api-python-client
8
+ Requires-Dist: google-auth-httplib2
9
+ Requires-Dist: google-auth-oauthlib
10
+ Requires-Dist: openai
11
+ Requires-Dist: python-dotenv
12
+ Requires-Dist: readchar
13
+ Requires-Dist: rich
14
+
15
+ # πŸš€ Nudge β€” AI Gmail Follow-up Agent
16
+
17
+ **Nudge** is an autonomous CLI agent that scans your sent Gmail threads, determines which emails need a follow-up using deterministic rules, and generates natural 2-4 sentence follow-up replies using **Groq AI (Llama 3.3 70B)**.
18
+
19
+ With human-in-the-loop approval, Nudge attaches approved drafts directly to the original Gmail thread.
20
+
21
+ ---
22
+
23
+ ## ✨ Features
24
+
25
+ - πŸ”’ **Secure OAuth 2.0 Integration**: Uses official Gmail API to read threads and create drafts.
26
+ - 🧠 **Smart Decision Engine**: Rule-based filtering:
27
+ - Skips threads where the recipient already replied.
28
+ - Skips threads where last message was sent < 3 days ago.
29
+ - Skips threads where you've sent 2+ follow-ups already.
30
+ - ⚑️ **Groq AI Generation**: Generates natural, human-sounding follow-ups powered by `llama-3.3-70b-versatile`.
31
+ - 🀝 **Human-in-the-Loop**: Interactive CLI prompt `[A] Approve | [S] Skip` before any draft is created.
32
+
33
+ ---
34
+
35
+ ## πŸ›  Project Structure
36
+
37
+ ```text
38
+ nudge/
39
+ β”œβ”€β”€ main.py # CLI Orchestrator & approval flow
40
+ β”œβ”€β”€ gmail_client.py # Gmail API client (auth, read threads, create draft)
41
+ β”œβ”€β”€ followup_agent.py # Decision logic & Groq AI generation
42
+ β”œβ”€β”€ .env.example # Environment variables template
43
+ β”œβ”€β”€ requirements.txt # Project dependencies
44
+ └── README.md
45
+ ```
46
+
47
+ ---
48
+
49
+ ## πŸš€ Quick Setup
50
+
51
+ 1. **Clone the repository**:
52
+ ```bash
53
+ git clone https://github.com/dhruvil-codes/nudge-agent.git
54
+ cd nudge-agent
55
+ ```
56
+
57
+ 2. **Create a virtual environment & install dependencies**:
58
+ ```bash
59
+ python -m venv venv
60
+ source venv/bin/activate # On Windows: .\venv\Scripts\Activate.ps1
61
+ pip install -r requirements.txt
62
+ ```
63
+
64
+ 3. **Configure Environment Variables**:
65
+ Create a `.env` file from `.env.example`:
66
+ ```bash
67
+ cp .env.example .env
68
+ ```
69
+ Add your Groq API Key:
70
+ ```env
71
+ GROQ_API_KEY=gsk_your_groq_api_key_here
72
+ ```
73
+
74
+ 4. **Add Google OAuth Credentials**:
75
+ - Download your OAuth Client ID credentials file from Google Cloud Console.
76
+ - Save it in the project root directory as `credentials.json`.
77
+
78
+ 5. **Run Nudge**:
79
+ ```bash
80
+ python main.py
81
+ ```
@@ -0,0 +1,8 @@
1
+ followup_agent.py,sha256=5iRBM5q8ZbcVg8ivVGA-QnQk2qbTW8-BFlTkvAAsAPI,5151
2
+ gmail_client.py,sha256=--uLhGl4w_oe2F5VbwkQGDe7QBg9rs0aNGociwXy3Fw,9645
3
+ main.py,sha256=NusQYXJ7-ESXtkttZ04SH9SomYGWWrGfFIp3okLj0VA,9639
4
+ nudge_agent-0.1.0.dist-info/METADATA,sha256=pRe-TZGfB-iHoaLrziU-5KVohCutIGyK42SiL0dP9zI,2544
5
+ nudge_agent-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ nudge_agent-0.1.0.dist-info/entry_points.txt,sha256=cH5zSSDXGdLwdGiQXCMNtIPjLZSC9fHS_jWXad_gDUQ,36
7
+ nudge_agent-0.1.0.dist-info/top_level.txt,sha256=NxjePV6U_dkYY_DngRBJIaLPAj7bVI3ogYqtsy7DXUk,33
8
+ nudge_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nudge = main:main
@@ -0,0 +1,3 @@
1
+ followup_agent
2
+ gmail_client
3
+ main