nexo-brain 7.29.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 +3 -1
- package/package.json +1 -1
- package/src/auto_update.py +72 -0
- package/src/automation_controls.py +180 -10
- package/src/automation_preferences.py +64 -20
- package/src/cli.py +37 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- 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 +6 -0
- package/src/scripts/nexo-morning-agent.py +263 -3
- package/src/scripts/nexo-send-reply.py +29 -1
- package/src/server.py +1 -0
- package/tool-enforcement-map.json +40 -0
|
@@ -38,8 +38,12 @@ import signal
|
|
|
38
38
|
import subprocess
|
|
39
39
|
import sys
|
|
40
40
|
import tempfile
|
|
41
|
+
import urllib.parse
|
|
42
|
+
import urllib.request
|
|
43
|
+
import xml.etree.ElementTree as ET
|
|
41
44
|
from datetime import date, datetime
|
|
42
45
|
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
43
47
|
|
|
44
48
|
_script_dir = Path(__file__).resolve().parent
|
|
45
49
|
_repo_src = _script_dir.parent
|
|
@@ -47,7 +51,7 @@ if str(_repo_src) not in sys.path:
|
|
|
47
51
|
sys.path.insert(0, str(_repo_src))
|
|
48
52
|
|
|
49
53
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
50
|
-
from automation_preferences import format_automation_preferences_prompt_block
|
|
54
|
+
from automation_preferences import format_automation_preferences_prompt_block, get_automation_preferences
|
|
51
55
|
from automation_controls import (
|
|
52
56
|
format_operator_extra_instructions_block,
|
|
53
57
|
get_operator_briefing_recipient_status,
|
|
@@ -82,6 +86,8 @@ MAX_ACTIVE_ITEMS = 8
|
|
|
82
86
|
MAX_DIARY_ITEMS = 6
|
|
83
87
|
MORNING_BRIEFING_STALE_HOURS = 12
|
|
84
88
|
_ACTIVE_CLAIM: dict[str, str] = {}
|
|
89
|
+
HTTP_TIMEOUT = 7
|
|
90
|
+
DEFAULT_NEWS_RSS_URL = "https://news.google.com/rss?hl=es&gl=ES&ceid=ES:es"
|
|
85
91
|
|
|
86
92
|
|
|
87
93
|
def log(message: str) -> None:
|
|
@@ -447,7 +453,250 @@ def _serialize_recent_sent_emails(*, limit: int = 8) -> list[dict]:
|
|
|
447
453
|
return result
|
|
448
454
|
|
|
449
455
|
|
|
450
|
-
def
|
|
456
|
+
def _fetch_json_url(url: str, *, timeout: int = HTTP_TIMEOUT) -> dict:
|
|
457
|
+
request = urllib.request.Request(
|
|
458
|
+
url,
|
|
459
|
+
headers={"User-Agent": "NEXO-Morning-Agent/1.0"},
|
|
460
|
+
)
|
|
461
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
462
|
+
raw = response.read(512_000)
|
|
463
|
+
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
|
464
|
+
return payload if isinstance(payload, dict) else {}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _fetch_text_url(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
|
468
|
+
request = urllib.request.Request(
|
|
469
|
+
url,
|
|
470
|
+
headers={"User-Agent": "NEXO-Morning-Agent/1.0"},
|
|
471
|
+
)
|
|
472
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
473
|
+
return response.read(512_000).decode("utf-8", errors="replace")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _desktop_settings_candidates() -> list[Path]:
|
|
477
|
+
home = Path.home()
|
|
478
|
+
candidates = [
|
|
479
|
+
Path(os.environ.get("NEXO_DESKTOP_SETTINGS", "")),
|
|
480
|
+
Path(os.environ.get("NEXO_DESKTOP_USER_DATA", "")) / "app-settings.json",
|
|
481
|
+
home / "Library" / "Application Support" / "nexo-desktop-mvp" / "app-settings.json",
|
|
482
|
+
home / "Library" / "Application Support" / "NEXO Desktop" / "app-settings.json",
|
|
483
|
+
home / "Library" / "Application Support" / "nexo-desktop" / "app-settings.json",
|
|
484
|
+
]
|
|
485
|
+
return [candidate for candidate in candidates if str(candidate)]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _read_desktop_settings() -> dict:
|
|
489
|
+
for candidate in _desktop_settings_candidates():
|
|
490
|
+
try:
|
|
491
|
+
if candidate.is_file():
|
|
492
|
+
payload = json.loads(candidate.read_text())
|
|
493
|
+
if isinstance(payload, dict):
|
|
494
|
+
return payload
|
|
495
|
+
except Exception:
|
|
496
|
+
continue
|
|
497
|
+
return {}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _normalize_location_candidate(value: object) -> dict:
|
|
501
|
+
if not value:
|
|
502
|
+
return {}
|
|
503
|
+
candidate = value
|
|
504
|
+
if isinstance(value, str):
|
|
505
|
+
text = value.strip()
|
|
506
|
+
if not text:
|
|
507
|
+
return {}
|
|
508
|
+
try:
|
|
509
|
+
candidate = json.loads(text)
|
|
510
|
+
except Exception:
|
|
511
|
+
return {"name": text}
|
|
512
|
+
if not isinstance(candidate, dict):
|
|
513
|
+
return {}
|
|
514
|
+
lat = candidate.get("lat", candidate.get("latitude"))
|
|
515
|
+
lon = candidate.get("lon", candidate.get("longitude"))
|
|
516
|
+
name = str(candidate.get("name") or candidate.get("display") or candidate.get("city") or "").strip()
|
|
517
|
+
try:
|
|
518
|
+
lat_value = float(lat)
|
|
519
|
+
lon_value = float(lon)
|
|
520
|
+
except Exception:
|
|
521
|
+
lat_value = None
|
|
522
|
+
lon_value = None
|
|
523
|
+
if lat_value is not None and lon_value is not None:
|
|
524
|
+
return {"lat": lat_value, "lon": lon_value, "name": name}
|
|
525
|
+
return {"name": name} if name else {}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _geocode_location_name(name: str, *, language: str = "es") -> dict:
|
|
529
|
+
clean = str(name or "").strip()
|
|
530
|
+
if not clean:
|
|
531
|
+
return {}
|
|
532
|
+
params = urllib.parse.urlencode({
|
|
533
|
+
"name": clean,
|
|
534
|
+
"count": "1",
|
|
535
|
+
"language": "en" if str(language).lower().startswith("en") else "es",
|
|
536
|
+
"format": "json",
|
|
537
|
+
})
|
|
538
|
+
payload = _fetch_json_url(f"https://geocoding-api.open-meteo.com/v1/search?{params}")
|
|
539
|
+
hit = (payload.get("results") or [None])[0]
|
|
540
|
+
if not isinstance(hit, dict):
|
|
541
|
+
return {}
|
|
542
|
+
try:
|
|
543
|
+
lat = float(hit.get("latitude"))
|
|
544
|
+
lon = float(hit.get("longitude"))
|
|
545
|
+
except Exception:
|
|
546
|
+
return {}
|
|
547
|
+
label_parts = [
|
|
548
|
+
str(hit.get("name") or clean).strip(),
|
|
549
|
+
str(hit.get("admin1") or "").strip(),
|
|
550
|
+
str(hit.get("country") or "").strip(),
|
|
551
|
+
]
|
|
552
|
+
return {
|
|
553
|
+
"lat": lat,
|
|
554
|
+
"lon": lon,
|
|
555
|
+
"name": ", ".join(part for part in label_parts if part),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _resolve_weather_location(profile: dict) -> dict:
|
|
560
|
+
settings = _read_desktop_settings()
|
|
561
|
+
app = settings.get("app") if isinstance(settings.get("app"), dict) else {}
|
|
562
|
+
app_loc = app.get("location") if isinstance(app.get("location"), dict) else {}
|
|
563
|
+
language = str(app.get("ui_language") or profile.get("language") or "es")
|
|
564
|
+
|
|
565
|
+
explicit = _normalize_location_candidate(app_loc)
|
|
566
|
+
if explicit.get("lat") is not None and explicit.get("lon") is not None:
|
|
567
|
+
return explicit
|
|
568
|
+
if explicit.get("name"):
|
|
569
|
+
try:
|
|
570
|
+
geocoded = _geocode_location_name(str(explicit.get("name")), language=language)
|
|
571
|
+
if geocoded:
|
|
572
|
+
return geocoded
|
|
573
|
+
except Exception:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
desktop_profile = settings.get("profile") if isinstance(settings.get("profile"), dict) else {}
|
|
577
|
+
for source in [
|
|
578
|
+
desktop_profile.get("current_residence"),
|
|
579
|
+
profile.get("current_residence"),
|
|
580
|
+
profile.get("location"),
|
|
581
|
+
profile.get("coordinates"),
|
|
582
|
+
]:
|
|
583
|
+
candidate = _normalize_location_candidate(source)
|
|
584
|
+
if candidate.get("lat") is not None and candidate.get("lon") is not None:
|
|
585
|
+
return candidate
|
|
586
|
+
if candidate.get("name"):
|
|
587
|
+
try:
|
|
588
|
+
geocoded = _geocode_location_name(str(candidate.get("name")), language=language)
|
|
589
|
+
if geocoded:
|
|
590
|
+
return geocoded
|
|
591
|
+
except Exception:
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
direct = _normalize_location_candidate({
|
|
595
|
+
"latitude": profile.get("latitude"),
|
|
596
|
+
"longitude": profile.get("longitude"),
|
|
597
|
+
"name": profile.get("current_residence") or "",
|
|
598
|
+
})
|
|
599
|
+
return direct
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _weather_code_label(code: object) -> str:
|
|
603
|
+
try:
|
|
604
|
+
value = int(code)
|
|
605
|
+
except Exception:
|
|
606
|
+
return ""
|
|
607
|
+
if value == 0:
|
|
608
|
+
return "clear"
|
|
609
|
+
if value in {1, 2, 3}:
|
|
610
|
+
return "partly cloudy"
|
|
611
|
+
if value in {45, 48}:
|
|
612
|
+
return "fog"
|
|
613
|
+
if value in {51, 53, 55, 56, 57}:
|
|
614
|
+
return "drizzle"
|
|
615
|
+
if value in {61, 63, 65, 66, 67, 80, 81, 82}:
|
|
616
|
+
return "rain"
|
|
617
|
+
if value in {71, 73, 75, 77, 85, 86}:
|
|
618
|
+
return "snow"
|
|
619
|
+
if value in {95, 96, 99}:
|
|
620
|
+
return "storm"
|
|
621
|
+
return "unknown"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _collect_weather(profile: dict) -> dict:
|
|
625
|
+
try:
|
|
626
|
+
loc = _resolve_weather_location(profile)
|
|
627
|
+
if not loc or loc.get("lat") is None or loc.get("lon") is None:
|
|
628
|
+
return {"available": False, "error": "no_location"}
|
|
629
|
+
params = urllib.parse.urlencode({
|
|
630
|
+
"latitude": loc["lat"],
|
|
631
|
+
"longitude": loc["lon"],
|
|
632
|
+
"current_weather": "true",
|
|
633
|
+
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
|
|
634
|
+
"timezone": "auto",
|
|
635
|
+
})
|
|
636
|
+
payload = _fetch_json_url(f"https://api.open-meteo.com/v1/forecast?{params}")
|
|
637
|
+
current = payload.get("current_weather") if isinstance(payload.get("current_weather"), dict) else {}
|
|
638
|
+
daily = payload.get("daily") if isinstance(payload.get("daily"), dict) else {}
|
|
639
|
+
if not current:
|
|
640
|
+
return {"available": False, "error": "weather_unavailable", "location": loc.get("name") or ""}
|
|
641
|
+
code = current.get("weathercode")
|
|
642
|
+
return {
|
|
643
|
+
"available": True,
|
|
644
|
+
"source": "open-meteo",
|
|
645
|
+
"location": loc.get("name") or "",
|
|
646
|
+
"temperature_c": current.get("temperature"),
|
|
647
|
+
"weather_code": code,
|
|
648
|
+
"weather": _weather_code_label(code),
|
|
649
|
+
"high_c": (daily.get("temperature_2m_max") or [None])[0],
|
|
650
|
+
"low_c": (daily.get("temperature_2m_min") or [None])[0],
|
|
651
|
+
"precipitation_probability_max": (daily.get("precipitation_probability_max") or [None])[0],
|
|
652
|
+
}
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
return {"available": False, "error": str(exc)[:240]}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _collect_news(profile: dict) -> dict:
|
|
658
|
+
try:
|
|
659
|
+
rss_url = str(profile.get("news_rss_url") or os.environ.get("NEXO_NEWS_RSS_URL") or DEFAULT_NEWS_RSS_URL).strip()
|
|
660
|
+
if not rss_url:
|
|
661
|
+
return {"available": False, "error": "no_news_source"}
|
|
662
|
+
xml_text = _fetch_text_url(rss_url)
|
|
663
|
+
root = ET.fromstring(xml_text)
|
|
664
|
+
items: list[dict] = []
|
|
665
|
+
for item in root.findall(".//item"):
|
|
666
|
+
title = _clean_text(item.findtext("title"), limit=220)
|
|
667
|
+
link = str(item.findtext("link") or "").strip()
|
|
668
|
+
source = _clean_text(item.findtext("source"), limit=80)
|
|
669
|
+
published = _clean_text(item.findtext("pubDate"), limit=120)
|
|
670
|
+
if title:
|
|
671
|
+
items.append({
|
|
672
|
+
"title": title,
|
|
673
|
+
"source": source,
|
|
674
|
+
"published": published,
|
|
675
|
+
"url": link[:500],
|
|
676
|
+
})
|
|
677
|
+
if len(items) >= 6:
|
|
678
|
+
break
|
|
679
|
+
return {
|
|
680
|
+
"available": bool(items),
|
|
681
|
+
"source": rss_url,
|
|
682
|
+
"headlines": items,
|
|
683
|
+
"error": "" if items else "empty_feed",
|
|
684
|
+
}
|
|
685
|
+
except Exception as exc:
|
|
686
|
+
return {"available": False, "error": str(exc)[:240], "headlines": []}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _collect_external_context(profile: dict, preferences: dict | None) -> dict:
|
|
690
|
+
values = preferences if isinstance(preferences, dict) else {}
|
|
691
|
+
external: dict[str, Any] = {}
|
|
692
|
+
if values.get("weather"):
|
|
693
|
+
external["weather"] = _collect_weather(profile)
|
|
694
|
+
if values.get("news"):
|
|
695
|
+
external["news"] = _collect_news(profile)
|
|
696
|
+
return external
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def collect_context(profile: dict, preferences: dict | None = None) -> dict:
|
|
451
700
|
nexo_db.init_db()
|
|
452
701
|
due_followups = _serialize_followups("due", limit=MAX_DUE_ITEMS)
|
|
453
702
|
due_followup_ids = {row["id"] for row in due_followups}
|
|
@@ -464,6 +713,7 @@ def collect_context(profile: dict) -> dict:
|
|
|
464
713
|
if row["id"] not in due_reminder_ids
|
|
465
714
|
][:MAX_ACTIVE_ITEMS]
|
|
466
715
|
recent_sent = _serialize_recent_sent_emails()
|
|
716
|
+
external = _collect_external_context(profile, preferences)
|
|
467
717
|
return {
|
|
468
718
|
"generated_at": datetime.now().astimezone().isoformat(),
|
|
469
719
|
"today": date.today().isoformat(),
|
|
@@ -471,6 +721,9 @@ def collect_context(profile: dict) -> dict:
|
|
|
471
721
|
"name": str(profile.get("operator_name") or "the operator"),
|
|
472
722
|
"language": str(profile.get("language") or "en"),
|
|
473
723
|
"email": str(profile.get("operator_email") or ""),
|
|
724
|
+
"role": str(profile.get("role") or ""),
|
|
725
|
+
"technical_level": str(profile.get("technical_level") or ""),
|
|
726
|
+
"residence": str(profile.get("current_residence") or ""),
|
|
474
727
|
},
|
|
475
728
|
"assistant": {
|
|
476
729
|
"name": str(profile.get("assistant_name") or "Nova"),
|
|
@@ -481,6 +734,7 @@ def collect_context(profile: dict) -> dict:
|
|
|
481
734
|
"active_followups": active_followups,
|
|
482
735
|
"recent_diaries": _serialize_diaries(limit=MAX_DIARY_ITEMS),
|
|
483
736
|
"recent_sent_emails_24h": recent_sent,
|
|
737
|
+
"external": external,
|
|
484
738
|
"counts": {
|
|
485
739
|
"due_reminders": len(due_reminders),
|
|
486
740
|
"active_reminders": len(active_reminders),
|
|
@@ -682,7 +936,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
682
936
|
_set_active_claim(today, recipient)
|
|
683
937
|
|
|
684
938
|
try:
|
|
685
|
-
|
|
939
|
+
preference_contract = get_automation_preferences("morning-agent")
|
|
940
|
+
preference_values = (
|
|
941
|
+
(preference_contract.get("preferences") or {}).get("values")
|
|
942
|
+
if isinstance(preference_contract, dict)
|
|
943
|
+
else {}
|
|
944
|
+
)
|
|
945
|
+
context = collect_context(profile, preference_values if isinstance(preference_values, dict) else {})
|
|
686
946
|
extra_blocks = "\n".join(
|
|
687
947
|
block
|
|
688
948
|
for block in [
|
|
@@ -160,6 +160,34 @@ def _message_id_domain(config: dict) -> str:
|
|
|
160
160
|
return "localhost"
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def _make_smtp_ssl_context() -> ssl.SSLContext:
|
|
164
|
+
"""Build a verified TLS context that survives macOS Python CA quirks."""
|
|
165
|
+
candidates: list[str] = []
|
|
166
|
+
try:
|
|
167
|
+
import certifi # type: ignore
|
|
168
|
+
|
|
169
|
+
candidates.append(certifi.where())
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
candidates.extend([
|
|
173
|
+
os.environ.get("SSL_CERT_FILE", ""),
|
|
174
|
+
"/etc/ssl/cert.pem",
|
|
175
|
+
"/usr/local/etc/openssl/cert.pem",
|
|
176
|
+
"/usr/local/etc/openssl@3/cert.pem",
|
|
177
|
+
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
178
|
+
])
|
|
179
|
+
for cafile in candidates:
|
|
180
|
+
if not cafile:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
path = Path(cafile)
|
|
184
|
+
if path.is_file():
|
|
185
|
+
return ssl.create_default_context(cafile=str(path))
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
return ssl.create_default_context()
|
|
189
|
+
|
|
190
|
+
|
|
163
191
|
def classify_reply_event(body_text):
|
|
164
192
|
normalized = normalize_reply_text(body_text).lower()
|
|
165
193
|
if not normalized:
|
|
@@ -403,7 +431,7 @@ def send_email(config, to, cc, subject, body_text, body_html, in_reply_to, refer
|
|
|
403
431
|
else:
|
|
404
432
|
clean_recipients.append(r.strip())
|
|
405
433
|
|
|
406
|
-
context =
|
|
434
|
+
context = _make_smtp_ssl_context()
|
|
407
435
|
server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"], context=context)
|
|
408
436
|
server.login(config["email"], config["password"])
|
|
409
437
|
server.sendmail(config["email"], clean_recipients, msg.as_string())
|
package/src/server.py
CHANGED
|
@@ -3,6 +3,46 @@
|
|
|
3
3
|
"version": "2.2.0",
|
|
4
4
|
"description": "Canonical map of all NEXO Brain MCP tools with enforcement rules, dependency chains, and behavioral hints. Source of truth for Protocol Enforcer (Desktop + headless). v2.1 adds schema support for Phase 2 event-driven rules (R01-R33): server_side_rules at tool level, per-rule mode (shadow|soft|hard), core_rule flag, and new rule types (pre_tool_intent, post_user_message, on_output_classify, before_tool_strict_block). Backward-compatible: executors that only handle v2.0 rule types ignore the new fields.",
|
|
5
5
|
"tools": {
|
|
6
|
+
"nexo_desktop_preferences_catalog": {
|
|
7
|
+
"description": "List Desktop/Brain preferences the agent can explain or change",
|
|
8
|
+
"category": "preferences",
|
|
9
|
+
"source": "plugin:desktop_preferences",
|
|
10
|
+
"requires": [],
|
|
11
|
+
"provides": ["preference_catalog"],
|
|
12
|
+
"internal_calls": [],
|
|
13
|
+
"enforcement": {"level": "none", "rules": []},
|
|
14
|
+
"triggers_after": []
|
|
15
|
+
},
|
|
16
|
+
"nexo_desktop_preference_get": {
|
|
17
|
+
"description": "Read one Desktop/Brain preference by id or alias",
|
|
18
|
+
"category": "preferences",
|
|
19
|
+
"source": "plugin:desktop_preferences",
|
|
20
|
+
"requires": ["preference_catalog"],
|
|
21
|
+
"provides": ["preference_value"],
|
|
22
|
+
"internal_calls": [],
|
|
23
|
+
"enforcement": {"level": "none", "rules": []},
|
|
24
|
+
"triggers_after": []
|
|
25
|
+
},
|
|
26
|
+
"nexo_desktop_preference_explain": {
|
|
27
|
+
"description": "Explain a Desktop/Brain preference in operator-friendly terms",
|
|
28
|
+
"category": "preferences",
|
|
29
|
+
"source": "plugin:desktop_preferences",
|
|
30
|
+
"requires": ["preference_catalog"],
|
|
31
|
+
"provides": ["preference_explanation"],
|
|
32
|
+
"internal_calls": [],
|
|
33
|
+
"enforcement": {"level": "none", "rules": []},
|
|
34
|
+
"triggers_after": []
|
|
35
|
+
},
|
|
36
|
+
"nexo_desktop_preference_set": {
|
|
37
|
+
"description": "Set one supported Desktop/Brain preference or preview with dry_run",
|
|
38
|
+
"category": "preferences",
|
|
39
|
+
"source": "plugin:desktop_preferences",
|
|
40
|
+
"requires": ["preference_catalog"],
|
|
41
|
+
"provides": ["preference_value"],
|
|
42
|
+
"internal_calls": [],
|
|
43
|
+
"enforcement": {"level": "standard", "rules": []},
|
|
44
|
+
"triggers_after": []
|
|
45
|
+
},
|
|
6
46
|
"nexo_adaptive_decay": {
|
|
7
47
|
"description": "Trigger inter-session tension decay",
|
|
8
48
|
"category": "adaptive",
|