@adaptic/maestro 1.6.1 → 1.7.0
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.
- package/package.json +1 -1
- package/scripts/continuous-monitor.sh +11 -6
- package/scripts/daemon/context-compiler.mjs +8 -8
- package/scripts/daemon/health.mjs +2 -2
- package/scripts/daemon/maestro-daemon.mjs +4 -3
- package/scripts/email_thread_dedup.py +4 -3
- package/scripts/huddle/huddle-server.mjs +50 -29
- package/scripts/llm_email_dedup.py +23 -15
- package/scripts/local-triggers/generate-plists.sh +2 -1
- package/scripts/media-generation/README.md +1 -1
- package/scripts/outbound-dedup-cleanup.sh +4 -4
- package/scripts/outbound-dedup.sh +4 -3
- package/scripts/pdf-generation/README.md +1 -1
- package/scripts/pdf-generation/templates/memo.latex +1 -1
- package/scripts/poll-slack-events.sh +4 -2
- package/scripts/poller/imap-client.mjs +11 -10
- package/scripts/poller/index.mjs +6 -6
- package/scripts/poller/intra-session-check.mjs +35 -18
- package/scripts/poller/mehran-gmail-poller.mjs +63 -29
- package/scripts/poller/slack-poller.mjs +45 -31
- package/scripts/poller/trigger.mjs +22 -5
- package/scripts/pre-draft-context.py +2 -2
- package/scripts/rag-indexer.py +3 -3
- package/scripts/send-sms.sh +7 -7
- package/scripts/send-whatsapp.sh +11 -11
- package/scripts/setup/configure-macos.sh +4 -2
- package/scripts/setup/init-agent.sh +1 -1
- package/scripts/slack-react.mjs +1 -1
- package/scripts/slack-typing.mjs +3 -3
- package/scripts/system-verify.sh +28 -15
- package/scripts/user-context-search.py +4 -4
- package/scripts/validate-outbound.py +29 -18
- package/scripts/sophie-inbox-poller.py +0 -406
|
@@ -1,406 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Sophie Gmail Inbox Poller — checks sophie@adaptic.ai for unseen emails via IMAP.
|
|
3
|
-
|
|
4
|
-
Designed to run as part of Sophie's polling infrastructure. Writes new inbox items
|
|
5
|
-
to state/inbox/gmail/ for the daemon's gmail-poller.mjs to pick up and classify.
|
|
6
|
-
|
|
7
|
-
Modeled on mehran-inbox-poller.py but for Sophie's own inbox.
|
|
8
|
-
|
|
9
|
-
Can be run standalone: python3 scripts/sophie-inbox-poller.py
|
|
10
|
-
Or called from gmail-poller.mjs before it reads the inbox directory.
|
|
11
|
-
|
|
12
|
-
Exit codes:
|
|
13
|
-
0 — success (even if no new mail)
|
|
14
|
-
1 — fatal error (missing credentials)
|
|
15
|
-
"""
|
|
16
|
-
import sys
|
|
17
|
-
import os
|
|
18
|
-
import re
|
|
19
|
-
import imaplib
|
|
20
|
-
import email
|
|
21
|
-
import json
|
|
22
|
-
import hashlib
|
|
23
|
-
import traceback
|
|
24
|
-
from email.header import decode_header
|
|
25
|
-
from email.utils import parsedate_to_datetime, parseaddr
|
|
26
|
-
from datetime import datetime, timezone
|
|
27
|
-
|
|
28
|
-
# ── Configuration ──
|
|
29
|
-
|
|
30
|
-
SOPHIE_EMAIL = "sophie@adaptic.ai"
|
|
31
|
-
MAX_EMAILS_PER_CYCLE = 20
|
|
32
|
-
SNIPPET_LENGTH = 500
|
|
33
|
-
|
|
34
|
-
# Paths (relative to repo root)
|
|
35
|
-
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
-
INBOX_DIR = os.path.join(REPO_ROOT, "state", "inbox", "gmail")
|
|
37
|
-
ATTACHMENTS_DIR = os.path.join(REPO_ROOT, "state", "inbox", "attachments")
|
|
38
|
-
CURSOR_FILE = os.path.join(REPO_ROOT, "state", "polling", "sophie-gmail-cursor.yaml")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# ── Credential Loading ──
|
|
42
|
-
|
|
43
|
-
def load_password():
|
|
44
|
-
"""Load GMAIL_APP_PASSWORD from environment or .env file."""
|
|
45
|
-
password = os.environ.get("GMAIL_APP_PASSWORD", "")
|
|
46
|
-
if password:
|
|
47
|
-
return password
|
|
48
|
-
|
|
49
|
-
env_path = os.path.join(REPO_ROOT, ".env")
|
|
50
|
-
if os.path.exists(env_path):
|
|
51
|
-
with open(env_path) as f:
|
|
52
|
-
for line in f:
|
|
53
|
-
line = line.strip()
|
|
54
|
-
if line.startswith("#") or "=" not in line:
|
|
55
|
-
continue
|
|
56
|
-
if line.startswith("GMAIL_APP_PASSWORD="):
|
|
57
|
-
val = line.split("=", 1)[1].strip()
|
|
58
|
-
if (val.startswith('"') and val.endswith('"')) or \
|
|
59
|
-
(val.startswith("'") and val.endswith("'")):
|
|
60
|
-
val = val[1:-1]
|
|
61
|
-
return val
|
|
62
|
-
return ""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ── Header Decoding ──
|
|
66
|
-
|
|
67
|
-
def decode_header_value(value):
|
|
68
|
-
"""Decode RFC 2047 encoded header value."""
|
|
69
|
-
if not value:
|
|
70
|
-
return ""
|
|
71
|
-
decoded_parts = decode_header(value)
|
|
72
|
-
result = []
|
|
73
|
-
for part, encoding in decoded_parts:
|
|
74
|
-
if isinstance(part, bytes):
|
|
75
|
-
result.append(part.decode(encoding or "utf-8", errors="replace"))
|
|
76
|
-
else:
|
|
77
|
-
result.append(str(part))
|
|
78
|
-
return " ".join(result)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# ── Body Extraction ──
|
|
82
|
-
|
|
83
|
-
def strip_html(html):
|
|
84
|
-
"""Crude HTML-to-text conversion using stdlib only (no external deps)."""
|
|
85
|
-
text = re.sub(r"<(style|script)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
86
|
-
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
|
87
|
-
text = re.sub(r"</(p|div|tr|li)>", "\n", text, flags=re.IGNORECASE)
|
|
88
|
-
text = re.sub(r"<[^>]+>", "", text)
|
|
89
|
-
text = text.replace(" ", " ").replace("&", "&")
|
|
90
|
-
text = text.replace("<", "<").replace(">", ">")
|
|
91
|
-
text = text.replace(""", '"').replace("'", "'")
|
|
92
|
-
text = re.sub(r"&\w+;", "", text)
|
|
93
|
-
text = re.sub(r"[ \t]+", " ", text)
|
|
94
|
-
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
95
|
-
return text.strip()
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def extract_attachments(msg, mid_hash):
|
|
99
|
-
"""Extract and save email attachments to local storage."""
|
|
100
|
-
attachments = []
|
|
101
|
-
os.makedirs(ATTACHMENTS_DIR, exist_ok=True)
|
|
102
|
-
|
|
103
|
-
if not msg.is_multipart():
|
|
104
|
-
return attachments
|
|
105
|
-
|
|
106
|
-
for part in msg.walk():
|
|
107
|
-
content_disposition = str(part.get("Content-Disposition", ""))
|
|
108
|
-
filename = part.get_filename()
|
|
109
|
-
|
|
110
|
-
if "attachment" not in content_disposition and not filename:
|
|
111
|
-
continue
|
|
112
|
-
if not filename:
|
|
113
|
-
continue
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
payload = part.get_payload(decode=True)
|
|
117
|
-
if not payload:
|
|
118
|
-
continue
|
|
119
|
-
|
|
120
|
-
content_type = part.get_content_type() or ""
|
|
121
|
-
if content_type.startswith("image/") and len(payload) < 1024:
|
|
122
|
-
continue
|
|
123
|
-
|
|
124
|
-
safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", filename)
|
|
125
|
-
local_name = f"{mid_hash}-{safe_name}"
|
|
126
|
-
local_path = os.path.join(ATTACHMENTS_DIR, local_name)
|
|
127
|
-
|
|
128
|
-
with open(local_path, "wb") as f:
|
|
129
|
-
f.write(payload)
|
|
130
|
-
|
|
131
|
-
attachments.append({
|
|
132
|
-
"name": filename,
|
|
133
|
-
"mimetype": content_type,
|
|
134
|
-
"size": len(payload),
|
|
135
|
-
"local_path": local_path,
|
|
136
|
-
})
|
|
137
|
-
print(f"[sophie-gmail] Attachment saved: {filename} ({len(payload)} bytes)")
|
|
138
|
-
|
|
139
|
-
except Exception as e:
|
|
140
|
-
print(f"[sophie-gmail] Attachment error for {filename}: {e}")
|
|
141
|
-
continue
|
|
142
|
-
|
|
143
|
-
return attachments
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def get_body_snippet(msg, max_length=SNIPPET_LENGTH):
|
|
147
|
-
"""Extract text body from email, truncated to max_length chars."""
|
|
148
|
-
plain_body = ""
|
|
149
|
-
html_body = ""
|
|
150
|
-
|
|
151
|
-
if msg.is_multipart():
|
|
152
|
-
for part in msg.walk():
|
|
153
|
-
content_type = part.get_content_type()
|
|
154
|
-
content_disposition = str(part.get("Content-Disposition", ""))
|
|
155
|
-
if "attachment" in content_disposition:
|
|
156
|
-
continue
|
|
157
|
-
try:
|
|
158
|
-
payload = part.get_payload(decode=True)
|
|
159
|
-
if not payload:
|
|
160
|
-
continue
|
|
161
|
-
charset = part.get_content_charset() or "utf-8"
|
|
162
|
-
decoded = payload.decode(charset, errors="replace")
|
|
163
|
-
if content_type == "text/plain" and not plain_body:
|
|
164
|
-
plain_body = decoded
|
|
165
|
-
elif content_type == "text/html" and not html_body:
|
|
166
|
-
html_body = decoded
|
|
167
|
-
except Exception:
|
|
168
|
-
pass
|
|
169
|
-
else:
|
|
170
|
-
try:
|
|
171
|
-
payload = msg.get_payload(decode=True)
|
|
172
|
-
if payload:
|
|
173
|
-
charset = msg.get_content_charset() or "utf-8"
|
|
174
|
-
decoded = payload.decode(charset, errors="replace")
|
|
175
|
-
if msg.get_content_type() == "text/html":
|
|
176
|
-
html_body = decoded
|
|
177
|
-
else:
|
|
178
|
-
plain_body = decoded
|
|
179
|
-
except Exception:
|
|
180
|
-
pass
|
|
181
|
-
|
|
182
|
-
body = plain_body.strip()
|
|
183
|
-
if not body and html_body:
|
|
184
|
-
body = strip_html(html_body)
|
|
185
|
-
|
|
186
|
-
if len(body) > max_length:
|
|
187
|
-
return body[:max_length] + "..."
|
|
188
|
-
return body
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# ── Cursor Management ──
|
|
192
|
-
|
|
193
|
-
def read_cursor():
|
|
194
|
-
"""Read the last poll state from cursor file."""
|
|
195
|
-
if not os.path.exists(CURSOR_FILE):
|
|
196
|
-
return {"last_poll": None, "last_message_id": None, "messages_processed": 0}
|
|
197
|
-
|
|
198
|
-
cursor = {}
|
|
199
|
-
try:
|
|
200
|
-
with open(CURSOR_FILE) as f:
|
|
201
|
-
for line in f:
|
|
202
|
-
line = line.strip()
|
|
203
|
-
if ":" not in line:
|
|
204
|
-
continue
|
|
205
|
-
key, val = line.split(":", 1)
|
|
206
|
-
key = key.strip()
|
|
207
|
-
val = val.strip().strip('"')
|
|
208
|
-
if val == "null" or val == "":
|
|
209
|
-
val = None
|
|
210
|
-
elif key == "messages_processed":
|
|
211
|
-
try:
|
|
212
|
-
val = int(val)
|
|
213
|
-
except ValueError:
|
|
214
|
-
val = 0
|
|
215
|
-
cursor[key] = val
|
|
216
|
-
except Exception:
|
|
217
|
-
return {"last_poll": None, "last_message_id": None, "messages_processed": 0}
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
"last_poll": cursor.get("last_poll"),
|
|
221
|
-
"last_message_id": cursor.get("last_message_id"),
|
|
222
|
-
"messages_processed": cursor.get("messages_processed", 0),
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def write_cursor(last_poll, last_message_id, messages_processed):
|
|
227
|
-
"""Write the poll state to cursor file."""
|
|
228
|
-
os.makedirs(os.path.dirname(CURSOR_FILE), exist_ok=True)
|
|
229
|
-
|
|
230
|
-
lp = f'"{last_poll}"' if last_poll else "null"
|
|
231
|
-
lm = f'"{last_message_id}"' if last_message_id else "null"
|
|
232
|
-
|
|
233
|
-
content = f"""last_poll: {lp}
|
|
234
|
-
last_message_id: {lm}
|
|
235
|
-
messages_processed: {messages_processed}
|
|
236
|
-
"""
|
|
237
|
-
with open(CURSOR_FILE, "w") as f:
|
|
238
|
-
f.write(content)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# ── Dedup ──
|
|
242
|
-
|
|
243
|
-
def message_id_hash(message_id, from_addr="", subject="", date=""):
|
|
244
|
-
"""Generate a short hash for deduplication."""
|
|
245
|
-
key = message_id or f"{from_addr}{subject}{date}"
|
|
246
|
-
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def is_already_processed(mid_hash):
|
|
250
|
-
"""Check if this message has already been written to inbox."""
|
|
251
|
-
base_name = f"{mid_hash}-email.json"
|
|
252
|
-
pending = os.path.join(INBOX_DIR, base_name)
|
|
253
|
-
processed = os.path.join(INBOX_DIR, base_name + ".processed")
|
|
254
|
-
return os.path.exists(pending) or os.path.exists(processed)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# ── Main Poll ──
|
|
258
|
-
|
|
259
|
-
def poll_sophie_inbox():
|
|
260
|
-
"""Poll Sophie's Gmail inbox for unseen emails and write to inbox queue."""
|
|
261
|
-
password = load_password()
|
|
262
|
-
if not password:
|
|
263
|
-
print("[sophie-gmail] GMAIL_APP_PASSWORD not set, skipping")
|
|
264
|
-
return 0
|
|
265
|
-
|
|
266
|
-
os.makedirs(INBOX_DIR, exist_ok=True)
|
|
267
|
-
|
|
268
|
-
cursor = read_cursor()
|
|
269
|
-
now = datetime.now(timezone.utc).isoformat()
|
|
270
|
-
new_count = 0
|
|
271
|
-
skipped = 0
|
|
272
|
-
last_mid = cursor.get("last_message_id")
|
|
273
|
-
total_processed = cursor.get("messages_processed", 0) or 0
|
|
274
|
-
|
|
275
|
-
mail = None
|
|
276
|
-
try:
|
|
277
|
-
mail = imaplib.IMAP4_SSL("imap.gmail.com")
|
|
278
|
-
mail.login(SOPHIE_EMAIL, password)
|
|
279
|
-
mail.select("INBOX")
|
|
280
|
-
|
|
281
|
-
status, data = mail.search(None, "UNSEEN")
|
|
282
|
-
if status != "OK" or not data[0]:
|
|
283
|
-
print("[sophie-gmail] No unseen messages")
|
|
284
|
-
write_cursor(now, last_mid, total_processed)
|
|
285
|
-
mail.logout()
|
|
286
|
-
return 0
|
|
287
|
-
|
|
288
|
-
msg_ids = data[0].split()
|
|
289
|
-
total_unseen = len(msg_ids)
|
|
290
|
-
print(f"[sophie-gmail] Found {total_unseen} unseen messages")
|
|
291
|
-
|
|
292
|
-
if len(msg_ids) > MAX_EMAILS_PER_CYCLE:
|
|
293
|
-
msg_ids = msg_ids[-MAX_EMAILS_PER_CYCLE:]
|
|
294
|
-
print(f"[sophie-gmail] Rate limited to last {MAX_EMAILS_PER_CYCLE}")
|
|
295
|
-
|
|
296
|
-
for mid in msg_ids:
|
|
297
|
-
try:
|
|
298
|
-
status, header_data = mail.fetch(
|
|
299
|
-
mid,
|
|
300
|
-
"(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)])"
|
|
301
|
-
)
|
|
302
|
-
if status != "OK" or not header_data[0]:
|
|
303
|
-
continue
|
|
304
|
-
|
|
305
|
-
raw_header = header_data[0][1]
|
|
306
|
-
msg_header = email.message_from_bytes(raw_header)
|
|
307
|
-
|
|
308
|
-
from_raw = msg_header.get("From", "")
|
|
309
|
-
subject_raw = msg_header.get("Subject", "")
|
|
310
|
-
date_raw = msg_header.get("Date", "")
|
|
311
|
-
message_id_raw = msg_header.get("Message-ID", "")
|
|
312
|
-
|
|
313
|
-
from_decoded = decode_header_value(from_raw)
|
|
314
|
-
subject_decoded = decode_header_value(subject_raw)
|
|
315
|
-
|
|
316
|
-
from_name, from_addr = parseaddr(from_decoded)
|
|
317
|
-
if not from_name:
|
|
318
|
-
from_name = from_addr
|
|
319
|
-
|
|
320
|
-
mid_hash = message_id_hash(
|
|
321
|
-
message_id_raw, from_addr, subject_decoded, date_raw
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
if is_already_processed(mid_hash):
|
|
325
|
-
skipped += 1
|
|
326
|
-
continue
|
|
327
|
-
|
|
328
|
-
snippet = ""
|
|
329
|
-
email_attachments = []
|
|
330
|
-
try:
|
|
331
|
-
status2, full_data = mail.fetch(mid, "(BODY.PEEK[])")
|
|
332
|
-
if status2 == "OK" and full_data[0]:
|
|
333
|
-
full_msg = email.message_from_bytes(full_data[0][1])
|
|
334
|
-
snippet = get_body_snippet(full_msg)
|
|
335
|
-
email_attachments = extract_attachments(full_msg, mid_hash)
|
|
336
|
-
except Exception:
|
|
337
|
-
snippet = ""
|
|
338
|
-
|
|
339
|
-
try:
|
|
340
|
-
parsed_date = parsedate_to_datetime(date_raw)
|
|
341
|
-
iso_date = parsed_date.isoformat()
|
|
342
|
-
except Exception:
|
|
343
|
-
iso_date = date_raw
|
|
344
|
-
|
|
345
|
-
record = {
|
|
346
|
-
"received_at": now,
|
|
347
|
-
"event_type": "sophie_email",
|
|
348
|
-
"source": "sophie_gmail_poll",
|
|
349
|
-
"email": {
|
|
350
|
-
"from": from_decoded,
|
|
351
|
-
"from_name": from_name,
|
|
352
|
-
"from_addr": from_addr,
|
|
353
|
-
"to": SOPHIE_EMAIL,
|
|
354
|
-
"subject": subject_decoded,
|
|
355
|
-
"date": iso_date,
|
|
356
|
-
"message_id": message_id_raw,
|
|
357
|
-
"snippet": snippet,
|
|
358
|
-
},
|
|
359
|
-
"attachments": email_attachments if email_attachments else None,
|
|
360
|
-
"triage_hint": None,
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
filepath = os.path.join(INBOX_DIR, f"{mid_hash}-email.json")
|
|
364
|
-
with open(filepath, "w") as f:
|
|
365
|
-
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
|
|
366
|
-
|
|
367
|
-
new_count += 1
|
|
368
|
-
last_mid = message_id_raw
|
|
369
|
-
print(f"[sophie-gmail] New: {from_name} <{from_addr}> — {subject_decoded[:60]}")
|
|
370
|
-
|
|
371
|
-
except Exception as e:
|
|
372
|
-
print(f"[sophie-gmail] Error processing message: {e}")
|
|
373
|
-
continue
|
|
374
|
-
|
|
375
|
-
total_processed += new_count
|
|
376
|
-
write_cursor(now, last_mid, total_processed)
|
|
377
|
-
mail.logout()
|
|
378
|
-
|
|
379
|
-
print(f"[sophie-gmail] Poll complete: {new_count} new, {skipped} skipped, {total_unseen} unseen total")
|
|
380
|
-
return new_count
|
|
381
|
-
|
|
382
|
-
except imaplib.IMAP4.error as e:
|
|
383
|
-
print(f"[sophie-gmail] IMAP error: {e}")
|
|
384
|
-
if mail:
|
|
385
|
-
try:
|
|
386
|
-
mail.logout()
|
|
387
|
-
except Exception:
|
|
388
|
-
pass
|
|
389
|
-
write_cursor(now, last_mid, total_processed)
|
|
390
|
-
return 0
|
|
391
|
-
|
|
392
|
-
except Exception as e:
|
|
393
|
-
print(f"[sophie-gmail] Connection error: {e}")
|
|
394
|
-
traceback.print_exc(file=sys.stdout)
|
|
395
|
-
if mail:
|
|
396
|
-
try:
|
|
397
|
-
mail.logout()
|
|
398
|
-
except Exception:
|
|
399
|
-
pass
|
|
400
|
-
write_cursor(now, last_mid, total_processed)
|
|
401
|
-
return 0
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if __name__ == "__main__":
|
|
405
|
-
count = poll_sophie_inbox()
|
|
406
|
-
print(f"[sophie-gmail] Finished — {count} new items written to inbox")
|