nexo-brain 7.28.0 → 7.30.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/src/cli_email.py CHANGED
@@ -222,6 +222,7 @@ def _account_to_public_dict(account: dict) -> dict:
222
222
  if not isinstance(metadata, dict):
223
223
  metadata = {}
224
224
  legacy_migrated = bool(metadata.get("migrated_from_legacy_email_config"))
225
+ signature = str(metadata.get("signature") or "").strip()
225
226
  return {
226
227
  "id": account.get("id"),
227
228
  "label": account.get("label"),
@@ -244,6 +245,8 @@ def _account_to_public_dict(account: dict) -> dict:
244
245
  "is_default": bool(account.get("is_default")),
245
246
  "has_credential": bool(account.get("credential_service")
246
247
  and account.get("credential_key")),
248
+ "signature_configured": bool(signature),
249
+ "signature_preview": " ".join(signature.split())[:120],
247
250
  }
248
251
 
249
252
 
@@ -626,6 +629,82 @@ def cmd_email_set_enabled(args) -> int:
626
629
  return 0
627
630
 
628
631
 
632
+ def cmd_email_signature(args) -> int:
633
+ json_mode = bool(getattr(args, "json", False))
634
+ account_id, label = _selector_from_args(args)
635
+ if account_id is None and not label:
636
+ msg = _selector_usage("signature")
637
+ if json_mode:
638
+ _emit_json({"ok": False, "message": msg})
639
+ else:
640
+ print(msg)
641
+ return 1
642
+
643
+ from db import init_db
644
+ from db._email_accounts import add_email_account, get_email_account, get_email_account_by_id
645
+
646
+ init_db()
647
+ acc = get_email_account_by_id(account_id) if account_id is not None else get_email_account(label)
648
+ if not acc:
649
+ selector = f"id={account_id}" if account_id is not None else label
650
+ msg = f"Account '{selector}' not found."
651
+ if json_mode:
652
+ _emit_json({"ok": False, "message": msg})
653
+ else:
654
+ print(f"✗ {msg}")
655
+ return 1
656
+
657
+ metadata = dict(acc.get("metadata") or {})
658
+ wants_set = any([
659
+ getattr(args, "text", None) is not None,
660
+ bool(getattr(args, "stdin", False)),
661
+ bool(getattr(args, "clear", False)),
662
+ ])
663
+ if wants_set:
664
+ if getattr(args, "clear", False):
665
+ metadata.pop("signature", None)
666
+ elif getattr(args, "stdin", False):
667
+ metadata["signature"] = sys.stdin.read().strip()
668
+ else:
669
+ metadata["signature"] = str(getattr(args, "text", "") or "").strip()
670
+ acc = add_email_account(
671
+ label=acc.get("label", ""),
672
+ email=acc.get("email", ""),
673
+ imap_host=acc.get("imap_host", ""),
674
+ imap_port=int(acc.get("imap_port") or 993),
675
+ smtp_host=acc.get("smtp_host", ""),
676
+ smtp_port=int(acc.get("smtp_port") or 465),
677
+ credential_service=acc.get("credential_service", ""),
678
+ credential_key=acc.get("credential_key", ""),
679
+ operator_email=acc.get("operator_email", ""),
680
+ trusted_domains=list(acc.get("trusted_domains") or []),
681
+ role=acc.get("role", "both"),
682
+ enabled=bool(acc.get("enabled", True)),
683
+ metadata=metadata,
684
+ account_type=acc.get("account_type", "agent"),
685
+ description=acc.get("description", ""),
686
+ can_read=bool(acc.get("can_read")),
687
+ can_send=bool(acc.get("can_send")),
688
+ is_default=bool(acc.get("is_default")),
689
+ )
690
+
691
+ signature = str((acc.get("metadata") or {}).get("signature") or "").strip()
692
+ payload = {
693
+ "ok": True,
694
+ "account": _account_to_public_dict(acc),
695
+ "signature": signature,
696
+ "cleared": wants_set and not bool(signature),
697
+ }
698
+ if json_mode:
699
+ _emit_json(payload)
700
+ else:
701
+ if wants_set:
702
+ print("Signature cleared." if not signature else "Signature saved.")
703
+ else:
704
+ print(signature or "(no signature configured)")
705
+ return 0
706
+
707
+
629
708
  def register_email_parser(subparsers) -> None:
630
709
  """Hook called by cli.py to add the `email` subcommand tree."""
631
710
  p = subparsers.add_parser("email", help="Manage NEXO email accounts")
@@ -704,6 +783,21 @@ def register_email_parser(subparsers) -> None:
704
783
  s.add_argument("--json", dest="json", action="store_true")
705
784
  s.set_defaults(func=cmd_email_set_enabled, enabled=False)
706
785
 
786
+ s = sub.add_parser("signature", help="Read or edit the signature for an account")
787
+ s.add_argument("label_pos", nargs="?", default=None,
788
+ help="Account label (legacy positional)")
789
+ s.add_argument("--label", dest="label", default=None)
790
+ s.add_argument("--id", dest="account_id", type=int, default=None)
791
+ sig_group = s.add_mutually_exclusive_group()
792
+ sig_group.add_argument("--text", dest="text", default=None,
793
+ help="Signature text to save")
794
+ sig_group.add_argument("--stdin", dest="stdin", action="store_true",
795
+ help="Read signature text from stdin")
796
+ sig_group.add_argument("--clear", dest="clear", action="store_true",
797
+ help="Clear the saved signature")
798
+ s.add_argument("--json", dest="json", action="store_true")
799
+ s.set_defaults(func=cmd_email_signature)
800
+
707
801
 
708
802
  __all__ = [
709
803
  "cmd_email_setup",
@@ -712,5 +806,6 @@ __all__ = [
712
806
  "cmd_email_test",
713
807
  "cmd_email_remove",
714
808
  "cmd_email_set_enabled",
809
+ "cmd_email_signature",
715
810
  "register_email_parser",
716
811
  ]
@@ -124,7 +124,7 @@ def resolve_declared_schedule(cron: dict) -> dict:
124
124
  resolved = dict(schedule)
125
125
  strategy = str(cron.get("schedule_strategy") or resolved.pop("strategy", "")).strip().lower()
126
126
  if strategy != "machine_weekly_spread":
127
- return resolved
127
+ return _normalize_declared_weekdays(resolved)
128
128
 
129
129
  if not {"hour", "minute", "weekday"} <= resolved.keys():
130
130
  return resolved
@@ -146,6 +146,41 @@ def resolve_declared_schedule(cron: dict) -> dict:
146
146
  }
147
147
 
148
148
 
149
+ def _normalize_weekdays(value) -> list[int] | None:
150
+ if value is None:
151
+ return None
152
+ if isinstance(value, str):
153
+ parts = [part.strip() for part in value.replace("+", ",").split(",")]
154
+ elif isinstance(value, (list, tuple, set)):
155
+ parts = list(value)
156
+ else:
157
+ return None
158
+ selected: set[int] = set()
159
+ for part in parts:
160
+ try:
161
+ selected.add(int(part) % 7)
162
+ except Exception:
163
+ continue
164
+ if len(selected) >= 7:
165
+ return []
166
+ order = (1, 2, 3, 4, 5, 6, 0)
167
+ return [day for day in order if day in selected]
168
+
169
+
170
+ def _normalize_declared_weekdays(schedule: dict) -> dict:
171
+ result = dict(schedule or {})
172
+ weekdays = _normalize_weekdays(result.get("weekdays"))
173
+ if weekdays is None and "weekday" in result:
174
+ weekdays = _normalize_weekdays([result.get("weekday")])
175
+ if weekdays:
176
+ result.pop("weekday", None)
177
+ result["weekdays"] = weekdays
178
+ elif weekdays == []:
179
+ result.pop("weekday", None)
180
+ result.pop("weekdays", None)
181
+ return result
182
+
183
+
149
184
  def load_enabled_crons() -> list[dict]:
150
185
  try:
151
186
  from automation_controls import apply_core_automation_overrides
@@ -276,7 +311,7 @@ def default_max_catchup_age(cron: dict) -> int:
276
311
  interval = int(cron["interval_seconds"])
277
312
  return max(interval * 4, interval + 900)
278
313
  schedule = cron.get("schedule") or {}
279
- if "weekday" in schedule:
314
+ if "weekday" in schedule or "weekdays" in schedule:
280
315
  return 14 * 86400
281
316
  if "hour" in schedule and "minute" in schedule:
282
317
  return 48 * 3600
@@ -478,14 +513,34 @@ def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
478
513
  return parsed
479
514
 
480
515
 
481
- def last_scheduled_time(calendar: dict, now: datetime | None = None) -> datetime:
516
+ def last_scheduled_time(calendar: dict | list, now: datetime | None = None) -> datetime:
482
517
  now = now or datetime.now().astimezone(_local_timezone())
483
518
  if now.tzinfo is None:
484
519
  now = now.replace(tzinfo=_local_timezone())
485
520
 
521
+ if isinstance(calendar, list):
522
+ candidates = [
523
+ last_scheduled_time(item, now)
524
+ for item in calendar
525
+ if isinstance(item, dict)
526
+ ]
527
+ if candidates:
528
+ return max(candidates)
529
+ return now
530
+
486
531
  hour = int(calendar.get("hour", calendar.get("Hour", 0)))
487
532
  minute = int(calendar.get("minute", calendar.get("Minute", 0)))
488
533
  weekday = calendar.get("weekday", calendar.get("Weekday"))
534
+ weekdays = calendar.get("weekdays") or calendar.get("Weekdays")
535
+ normalized_weekdays = _normalize_weekdays(weekdays)
536
+ if normalized_weekdays:
537
+ base_calendar = dict(calendar)
538
+ base_calendar.pop("weekdays", None)
539
+ base_calendar.pop("Weekdays", None)
540
+ return max(
541
+ last_scheduled_time({**base_calendar, "weekday": day}, now)
542
+ for day in normalized_weekdays
543
+ )
489
544
 
490
545
  today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
491
546
  if weekday is not None:
package/src/crons/sync.py CHANGED
@@ -419,6 +419,43 @@ def _copy_into_runtime(src: Path) -> Path:
419
419
  return dest
420
420
 
421
421
 
422
+ def _calendar_weekdays(schedule: dict) -> list[int]:
423
+ raw = schedule.get("weekdays") or schedule.get("Weekdays")
424
+ if raw is None and "weekday" in schedule:
425
+ raw = [schedule.get("weekday")]
426
+ if raw is None and "Weekday" in schedule:
427
+ raw = [schedule.get("Weekday")]
428
+ if isinstance(raw, str):
429
+ parts = [part.strip() for part in raw.replace("+", ",").split(",")]
430
+ elif isinstance(raw, (list, tuple, set)):
431
+ parts = list(raw)
432
+ else:
433
+ return []
434
+ selected: set[int] = set()
435
+ for part in parts:
436
+ try:
437
+ selected.add(int(part) % 7)
438
+ except Exception:
439
+ continue
440
+ if len(selected) >= 7:
441
+ return []
442
+ return [day for day in (1, 2, 3, 4, 5, 6, 0) if day in selected]
443
+
444
+
445
+ def _launchd_calendar_intervals(schedule: dict) -> dict | list[dict]:
446
+ base = {}
447
+ if "hour" in schedule:
448
+ base["Hour"] = schedule["hour"]
449
+ if "minute" in schedule:
450
+ base["Minute"] = schedule["minute"]
451
+ weekdays = _calendar_weekdays(schedule)
452
+ if weekdays:
453
+ if len(weekdays) == 1:
454
+ return {**base, "Weekday": weekdays[0]}
455
+ return [{**base, "Weekday": day} for day in weekdays]
456
+ return base
457
+
458
+
422
459
  def build_plist(cron: dict) -> dict:
423
460
  """Build a macOS LaunchAgent plist dict from a manifest entry."""
424
461
  cron_id = cron["id"]
@@ -480,15 +517,8 @@ def build_plist(cron: dict) -> dict:
480
517
  if "interval_seconds" in cron and not cron.get("keep_alive"):
481
518
  plist["StartInterval"] = cron["interval_seconds"]
482
519
  elif "schedule" in cron and not cron.get("keep_alive"):
483
- cal = {}
484
520
  s = resolve_declared_schedule(cron)
485
- if "hour" in s:
486
- cal["Hour"] = s["hour"]
487
- if "minute" in s:
488
- cal["Minute"] = s["minute"]
489
- if "weekday" in s:
490
- cal["Weekday"] = s["weekday"]
491
- plist["StartCalendarInterval"] = cal
521
+ plist["StartCalendarInterval"] = _launchd_calendar_intervals(s)
492
522
 
493
523
  return plist
494
524
 
@@ -513,9 +543,9 @@ def _cron_schedule(cron: dict) -> str | None:
513
543
  s = resolve_declared_schedule(cron)
514
544
  hour, minute = int(s.get("hour", 0)), int(s.get("minute", 0))
515
545
  weekday = "*"
516
- if "weekday" in s:
517
- raw_weekday = int(s["weekday"])
518
- weekday = "0" if raw_weekday == 7 else str(raw_weekday)
546
+ weekdays = _calendar_weekdays(s)
547
+ if weekdays:
548
+ weekday = ",".join("0" if int(day) == 7 else str(int(day) % 7) for day in weekdays)
519
549
  return f"{minute} {hour} * * {weekday}"
520
550
  return None
521
551
 
@@ -916,10 +946,13 @@ StandardError=append:{stderr_log}
916
946
  elif "schedule" in cron:
917
947
  s = resolve_declared_schedule(cron)
918
948
  h, m = s.get("hour", 0), s.get("minute", 0)
919
- if "weekday" in s:
920
- # Manifest weekday uses launchd convention: 0=Sunday … 6=Saturday (7=Sunday alias)
949
+ weekdays = _calendar_weekdays(s)
950
+ if weekdays:
921
951
  days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
922
- timer_spec = f"OnCalendar={days[s['weekday']]} *-*-* {h:02d}:{m:02d}:00"
952
+ timer_spec = "\n".join(
953
+ f"OnCalendar={days[int(day) % 7]} *-*-* {h:02d}:{m:02d}:00"
954
+ for day in weekdays
955
+ )
923
956
  else:
924
957
  timer_spec = f"OnCalendar=*-*-* {h:02d}:{m:02d}:00"
925
958
  else:
package/src/db/_schema.py CHANGED
@@ -2758,6 +2758,23 @@ def _m76_semantic_layers(conn):
2758
2758
  _migrate_add_index(conn, "idx_semantic_layer_sources_kind", "semantic_layer_source_refs", "source_kind, validation_status")
2759
2759
 
2760
2760
 
2761
+ def _m77_morning_briefing_presentation(conn):
2762
+ """Persist sanitized briefing bodies and Desktop read-state."""
2763
+ _m58_morning_briefing_runs(conn)
2764
+ _migrate_add_column(conn, "morning_briefing_runs", "body_text", "TEXT DEFAULT ''")
2765
+ _migrate_add_column(conn, "morning_briefing_runs", "body_html", "TEXT DEFAULT ''")
2766
+ _migrate_add_column(conn, "morning_briefing_runs", "artifact_json", "TEXT DEFAULT ''")
2767
+ _migrate_add_column(conn, "morning_briefing_runs", "desktop_shown_at", "TEXT DEFAULT NULL")
2768
+ _migrate_add_column(conn, "morning_briefing_runs", "desktop_opened_at", "TEXT DEFAULT NULL")
2769
+ _migrate_add_column(conn, "morning_briefing_runs", "desktop_dismissed_at", "TEXT DEFAULT NULL")
2770
+ _migrate_add_index(
2771
+ conn,
2772
+ "idx_morning_briefing_runs_desktop",
2773
+ "morning_briefing_runs",
2774
+ "status, desktop_shown_at, finished_at",
2775
+ )
2776
+
2777
+
2761
2778
  MIGRATIONS = [
2762
2779
  (1, "learnings_columns", _m1_learnings_columns),
2763
2780
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -2835,6 +2852,7 @@ MIGRATIONS = [
2835
2852
  (74, "entity_live_profiles", _m74_entity_live_profiles),
2836
2853
  (75, "failure_prevention_ledger", _m75_failure_prevention_ledger),
2837
2854
  (76, "semantic_layers", _m76_semantic_layers),
2855
+ (77, "morning_briefing_presentation", _m77_morning_briefing_presentation),
2838
2856
  ]
2839
2857
 
2840
2858
 
@@ -0,0 +1,243 @@
1
+ """Shared email presentation helpers for operator-facing automations.
2
+
3
+ Agents may produce HTML, but SMTP, artifacts, and Desktop must only consume
4
+ normalized/sanitized output from this module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ import re
11
+ from dataclasses import dataclass
12
+ from html.parser import HTMLParser
13
+ from typing import Any
14
+ from urllib.parse import urlparse
15
+
16
+
17
+ ALLOWED_TAGS = {
18
+ "a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
19
+ "hr", "i", "li", "ol", "p", "pre", "span", "strong", "table", "tbody",
20
+ "td", "th", "thead", "tr", "u", "ul",
21
+ }
22
+ VOID_TAGS = {"br", "hr"}
23
+ ALLOWED_ATTRS = {
24
+ "a": {"href", "title"},
25
+ "td": {"colspan", "rowspan"},
26
+ "th": {"colspan", "rowspan"},
27
+ }
28
+ SAFE_URL_SCHEMES = {"http", "https", "mailto"}
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class EmailPresentation:
33
+ subject: str
34
+ body_text: str
35
+ body_html: str
36
+ input_format: str
37
+
38
+ def to_dict(self) -> dict[str, str]:
39
+ return {
40
+ "subject": self.subject,
41
+ "body_text": self.body_text,
42
+ "body_html": self.body_html,
43
+ "input_format": self.input_format,
44
+ }
45
+
46
+
47
+ class _SafeHtmlParser(HTMLParser):
48
+ def __init__(self) -> None:
49
+ super().__init__(convert_charrefs=True)
50
+ self.parts: list[str] = []
51
+ self._skip_depth = 0
52
+
53
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
54
+ clean_tag = tag.lower()
55
+ if clean_tag in {"script", "style", "iframe", "object", "embed", "svg", "math"}:
56
+ self._skip_depth += 1
57
+ return
58
+ if self._skip_depth or clean_tag not in ALLOWED_TAGS:
59
+ return
60
+ attr_bits: list[str] = []
61
+ allowed = ALLOWED_ATTRS.get(clean_tag, set())
62
+ for raw_name, raw_value in attrs:
63
+ name = str(raw_name or "").lower().strip()
64
+ if not name or name.startswith("on") or name not in allowed:
65
+ continue
66
+ value = str(raw_value or "").strip()
67
+ if name == "href" and not _safe_href(value):
68
+ continue
69
+ if name in {"colspan", "rowspan"}:
70
+ value = str(max(1, min(12, _safe_int(value, 1))))
71
+ attr_bits.append(f'{name}="{html.escape(value, quote=True)}"')
72
+ suffix = (" " + " ".join(attr_bits)) if attr_bits else ""
73
+ self.parts.append(f"<{clean_tag}{suffix}>")
74
+
75
+ def handle_endtag(self, tag: str) -> None:
76
+ clean_tag = tag.lower()
77
+ if clean_tag in {"script", "style", "iframe", "object", "embed", "svg", "math"}:
78
+ if self._skip_depth:
79
+ self._skip_depth -= 1
80
+ return
81
+ if self._skip_depth or clean_tag not in ALLOWED_TAGS or clean_tag in VOID_TAGS:
82
+ return
83
+ self.parts.append(f"</{clean_tag}>")
84
+
85
+ def handle_data(self, data: str) -> None:
86
+ if not self._skip_depth:
87
+ self.parts.append(html.escape(data, quote=False))
88
+
89
+ def handle_entityref(self, name: str) -> None:
90
+ if not self._skip_depth:
91
+ self.parts.append(f"&{name};")
92
+
93
+ def handle_charref(self, name: str) -> None:
94
+ if not self._skip_depth:
95
+ self.parts.append(f"&#{name};")
96
+
97
+
98
+ def _safe_int(value: Any, fallback: int) -> int:
99
+ try:
100
+ return int(value)
101
+ except Exception:
102
+ return fallback
103
+
104
+
105
+ def _safe_href(value: str) -> bool:
106
+ parsed = urlparse(value)
107
+ if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
108
+ return False
109
+ if not parsed.scheme and value.strip().lower().startswith("javascript:"):
110
+ return False
111
+ return True
112
+
113
+
114
+ def sanitize_html_fragment(raw_html: str) -> str:
115
+ parser = _SafeHtmlParser()
116
+ try:
117
+ parser.feed(str(raw_html or ""))
118
+ parser.close()
119
+ except Exception:
120
+ return ""
121
+ cleaned = "".join(parser.parts)
122
+ cleaned = re.sub(r"\s+javascript\s*:", "", cleaned, flags=re.I)
123
+ return cleaned.strip()
124
+
125
+
126
+ def text_to_html_fragment(text: str) -> str:
127
+ paragraphs = re.split(r"\n{2,}", str(text or "").strip())
128
+ rendered: list[str] = []
129
+ for paragraph in paragraphs:
130
+ clean = html.escape(paragraph.strip(), quote=False)
131
+ if not clean:
132
+ continue
133
+ rendered.append(f"<p>{clean.replace(chr(10), '<br>')}</p>")
134
+ return "".join(rendered) or "<p></p>"
135
+
136
+
137
+ def html_to_text(raw_html: str) -> str:
138
+ text = re.sub(r"(?is)<(script|style|iframe|object|embed|svg|math).*?</\1>", " ", str(raw_html or ""))
139
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
140
+ text = re.sub(r"(?i)</(p|div|li|h1|h2|h3|tr)>", "\n", text)
141
+ text = re.sub(r"<[^>]+>", " ", text)
142
+ text = html.unescape(text)
143
+ lines = [" ".join(line.split()) for line in text.splitlines()]
144
+ return "\n".join(line for line in lines if line).strip()
145
+
146
+
147
+ def compose_html_document(fragment: str) -> str:
148
+ safe_fragment = sanitize_html_fragment(fragment)
149
+ return (
150
+ '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
151
+ '<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;'
152
+ 'font-size:14px;color:#222;line-height:1.6;">'
153
+ f"{safe_fragment}</body></html>"
154
+ )
155
+
156
+
157
+ def signature_from_config(config: dict | None, *, fallback: str = "") -> str:
158
+ metadata = (config or {}).get("metadata")
159
+ if not isinstance(metadata, dict):
160
+ account = (config or {}).get("agent_account")
161
+ if isinstance(account, dict):
162
+ metadata = account.get("metadata")
163
+ if not isinstance(metadata, dict):
164
+ metadata = {}
165
+ signature = str(metadata.get("signature") or "").strip()
166
+ return signature or str(fallback or "").strip()
167
+
168
+
169
+ def append_signature_text(body_text: str, signature: str) -> str:
170
+ clean_body = str(body_text or "").strip()
171
+ clean_signature = str(signature or "").strip()
172
+ if not clean_signature:
173
+ return clean_body
174
+ if clean_signature in clean_body[-500:]:
175
+ return clean_body
176
+ return f"{clean_body}\n\n-- \n{clean_signature}".strip()
177
+
178
+
179
+ def append_signature_html(fragment: str, signature: str) -> str:
180
+ clean_signature = str(signature or "").strip()
181
+ if not clean_signature:
182
+ return fragment
183
+ safe_signature = text_to_html_fragment(clean_signature)
184
+ return (
185
+ f"{fragment}"
186
+ '<hr style="border:none;border-top:1px solid #ddd;margin:20px 0;">'
187
+ f'<div style="color:#666;font-size:12px;">{safe_signature}</div>'
188
+ )
189
+
190
+
191
+ def build_email_presentation(
192
+ *,
193
+ subject: str,
194
+ body_text: str = "",
195
+ body_html: str = "",
196
+ signature: str = "",
197
+ include_signature: bool = False,
198
+ ) -> EmailPresentation:
199
+ clean_subject = " ".join(str(subject or "").split()).strip()
200
+ raw_text = str(body_text or "").strip()
201
+ raw_html = str(body_html or "").strip()
202
+ input_format = "html" if raw_html else "text"
203
+ text = raw_text or html_to_text(raw_html)
204
+ html_fragment = sanitize_html_fragment(raw_html) if raw_html else text_to_html_fragment(text)
205
+ if include_signature:
206
+ text = append_signature_text(text, signature)
207
+ html_fragment = append_signature_html(html_fragment, signature)
208
+ return EmailPresentation(
209
+ subject=clean_subject,
210
+ body_text=text,
211
+ body_html=compose_html_document(html_fragment),
212
+ input_format=input_format,
213
+ )
214
+
215
+
216
+ def normalize_agent_email_payload(payload: dict[str, Any], *, signature: str = "") -> EmailPresentation:
217
+ subject = str(payload.get("subject") or "").strip()
218
+ body_text = str(payload.get("body_text") or payload.get("body") or "").strip()
219
+ body_html = str(payload.get("body_html") or "").strip()
220
+ presentation = build_email_presentation(
221
+ subject=subject,
222
+ body_text=body_text,
223
+ body_html=body_html,
224
+ signature=signature,
225
+ include_signature=bool(signature),
226
+ )
227
+ if not presentation.subject or not presentation.body_text:
228
+ raise RuntimeError("Email payload is missing subject/body_text.")
229
+ return presentation
230
+
231
+
232
+ __all__ = [
233
+ "EmailPresentation",
234
+ "append_signature_html",
235
+ "append_signature_text",
236
+ "build_email_presentation",
237
+ "compose_html_document",
238
+ "html_to_text",
239
+ "normalize_agent_email_payload",
240
+ "sanitize_html_fragment",
241
+ "signature_from_config",
242
+ "text_to_html_fragment",
243
+ ]
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "claude_code": {
4
- "model": "claude-opus-4-7[1m]",
4
+ "model": "claude-opus-4-8",
5
5
  "reasoning_effort": "max",
6
- "display_name": "Opus 4.7 with 1M context",
7
- "recommendation_version": 2,
8
- "previous_defaults": ["claude-opus-4-6[1m]"]
6
+ "display_name": "Opus 4.8 with max reasoning",
7
+ "recommendation_version": 3,
8
+ "previous_defaults": ["claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]"]
9
9
  },
10
10
  "codex": {
11
11
  "model": "gpt-5.5",
@@ -20,11 +20,11 @@ from typing import Any
20
20
  _FALLBACK: dict[str, Any] = {
21
21
  "schema_version": 1,
22
22
  "claude_code": {
23
- "model": "claude-opus-4-7[1m]",
23
+ "model": "claude-opus-4-8",
24
24
  "reasoning_effort": "max",
25
- "display_name": "Opus 4.7 with 1M context",
26
- "recommendation_version": 2,
27
- "previous_defaults": ["claude-opus-4-6[1m]"],
25
+ "display_name": "Opus 4.8 with max reasoning",
26
+ "recommendation_version": 3,
27
+ "previous_defaults": ["claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]"],
28
28
  },
29
29
  "codex": {
30
30
  "model": "gpt-5.5",
@@ -99,14 +99,14 @@ def looks_like_claude_model(model: str) -> bool:
99
99
  return str(model or "").strip().lower().startswith(_CLAUDE_MODEL_PREFIXES)
100
100
 
101
101
 
102
- _OPUS_46_PREFIX = "claude-opus-4-6"
102
+ _CLAUDE_DEFAULT_PREFIXES = ("claude-opus-4-6", "claude-opus-4-7")
103
103
 
104
104
 
105
105
  def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
106
106
  """Detect and repair invalid models in client_runtime_profiles. Returns
107
107
  (healed_profiles_dict, list_of_heal_messages). Handles two cases:
108
108
  1. Claude-family model in the codex profile (historical bug).
109
- 2. Opus 4.6 → 4.7 auto-migration for claude_code users on a NEXO default."""
109
+ 2. Opus default auto-migration for claude_code users on a NEXO default."""
110
110
  if not isinstance(profiles, dict):
111
111
  return profiles, []
112
112
  healed = dict(profiles)
@@ -127,14 +127,13 @@ def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
127
127
  f"(Claude models are invalid for Codex)."
128
128
  )
129
129
 
130
- # --- Opus 4.6 → 4.7 auto-migration for claude_code ---
130
+ # --- Opus default auto-migration for claude_code ---
131
131
  cc_profile = healed.get("claude_code") if isinstance(healed.get("claude_code"), dict) else None
132
132
  if cc_profile is not None:
133
133
  cc_model = str(cc_profile.get("model") or "").strip()
134
- if cc_model.startswith(_OPUS_46_PREFIX):
134
+ if any(cc_model.startswith(prefix) for prefix in _CLAUDE_DEFAULT_PREFIXES):
135
135
  default = client_default("claude_code")
136
- suffix = cc_model[len(_OPUS_46_PREFIX):]
137
- new_model = f"claude-opus-4-7{suffix}"
136
+ new_model = default["model"]
138
137
  old_effort = str(cc_profile.get("reasoning_effort") or "").strip()
139
138
  new_effort = default["reasoning_effort"]
140
139
  healed["claude_code"] = dict(cc_profile)