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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +72 -0
- package/src/automation_controls.py +187 -10
- package/src/automation_preferences.py +367 -0
- package/src/cli.py +157 -0
- package/src/cli_email.py +95 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/db/_schema.py +18 -0
- package/src/email_presentation.py +243 -0
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- package/src/morning_briefing.py +281 -0
- package/src/plugins/desktop_preferences.py +63 -0
- package/src/plugins/personal_scripts.py +2 -0
- package/src/plugins/update.py +4 -0
- package/src/preference_catalog.py +438 -0
- package/src/resonance_tiers.json +4 -4
- package/src/script_registry.py +21 -0
- package/src/scripts/nexo-morning-agent.py +380 -71
- package/src/scripts/nexo-send-reply.py +49 -26
- package/src/server.py +1 -0
- package/templates/core-prompts/morning-agent-json-output.md +1 -1
- package/templates/core-prompts/morning-agent.md +5 -2
- package/tool-enforcement-map.json +40 -0
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
|
]
|
package/src/cron_recovery.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
weekday = "0" if
|
|
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
|
-
|
|
920
|
-
|
|
949
|
+
weekdays = _calendar_weekdays(s)
|
|
950
|
+
if weekdays:
|
|
921
951
|
days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
922
|
-
timer_spec =
|
|
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
|
+
]
|
package/src/model_defaults.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"claude_code": {
|
|
4
|
-
"model": "claude-opus-4-
|
|
4
|
+
"model": "claude-opus-4-8",
|
|
5
5
|
"reasoning_effort": "max",
|
|
6
|
-
"display_name": "Opus 4.
|
|
7
|
-
"recommendation_version":
|
|
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",
|
package/src/model_defaults.py
CHANGED
|
@@ -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-
|
|
23
|
+
"model": "claude-opus-4-8",
|
|
24
24
|
"reasoning_effort": "max",
|
|
25
|
-
"display_name": "Opus 4.
|
|
26
|
-
"recommendation_version":
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
134
|
+
if any(cc_model.startswith(prefix) for prefix in _CLAUDE_DEFAULT_PREFIXES):
|
|
135
135
|
default = client_default("claude_code")
|
|
136
|
-
|
|
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)
|