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 +145 -0
- gmail_client.py +278 -0
- main.py +220 -0
- nudge_agent-0.1.0.dist-info/METADATA +81 -0
- nudge_agent-0.1.0.dist-info/RECORD +8 -0
- nudge_agent-0.1.0.dist-info/WHEEL +5 -0
- nudge_agent-0.1.0.dist-info/entry_points.txt +2 -0
- nudge_agent-0.1.0.dist-info/top_level.txt +3 -0
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,,
|