@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/scripts/continuous-monitor.sh +11 -6
  3. package/scripts/daemon/context-compiler.mjs +8 -8
  4. package/scripts/daemon/health.mjs +2 -2
  5. package/scripts/daemon/maestro-daemon.mjs +4 -3
  6. package/scripts/email_thread_dedup.py +4 -3
  7. package/scripts/huddle/huddle-server.mjs +50 -29
  8. package/scripts/llm_email_dedup.py +23 -15
  9. package/scripts/local-triggers/generate-plists.sh +2 -1
  10. package/scripts/media-generation/README.md +1 -1
  11. package/scripts/outbound-dedup-cleanup.sh +4 -4
  12. package/scripts/outbound-dedup.sh +4 -3
  13. package/scripts/pdf-generation/README.md +1 -1
  14. package/scripts/pdf-generation/templates/memo.latex +1 -1
  15. package/scripts/poll-slack-events.sh +4 -2
  16. package/scripts/poller/imap-client.mjs +11 -10
  17. package/scripts/poller/index.mjs +6 -6
  18. package/scripts/poller/intra-session-check.mjs +35 -18
  19. package/scripts/poller/mehran-gmail-poller.mjs +63 -29
  20. package/scripts/poller/slack-poller.mjs +45 -31
  21. package/scripts/poller/trigger.mjs +22 -5
  22. package/scripts/pre-draft-context.py +2 -2
  23. package/scripts/rag-indexer.py +3 -3
  24. package/scripts/send-sms.sh +7 -7
  25. package/scripts/send-whatsapp.sh +11 -11
  26. package/scripts/setup/configure-macos.sh +4 -2
  27. package/scripts/setup/init-agent.sh +1 -1
  28. package/scripts/slack-react.mjs +1 -1
  29. package/scripts/slack-typing.mjs +3 -3
  30. package/scripts/system-verify.sh +28 -15
  31. package/scripts/user-context-search.py +4 -4
  32. package/scripts/validate-outbound.py +29 -18
  33. 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("&nbsp;", " ").replace("&amp;", "&")
90
- text = text.replace("&lt;", "<").replace("&gt;", ">")
91
- text = text.replace("&quot;", '"').replace("&#39;", "'")
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")