nexo-brain 7.28.0 → 7.29.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/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
+ ]
@@ -0,0 +1,281 @@
1
+ """Morning briefing persistence and Desktop-facing accessors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import db as nexo_db
11
+ from paths import operations_dir
12
+
13
+
14
+ LATEST_MARKDOWN_FILE = operations_dir() / "morning-briefing-latest.md"
15
+ LATEST_HTML_FILE = operations_dir() / "morning-briefing-latest.html"
16
+ LATEST_JSON_FILE = operations_dir() / "morning-briefing-latest.json"
17
+
18
+ PRESENTATION_COLUMNS = {
19
+ "body_text": "TEXT DEFAULT ''",
20
+ "body_html": "TEXT DEFAULT ''",
21
+ "artifact_json": "TEXT DEFAULT ''",
22
+ "desktop_shown_at": "TEXT DEFAULT NULL",
23
+ "desktop_opened_at": "TEXT DEFAULT NULL",
24
+ "desktop_dismissed_at": "TEXT DEFAULT NULL",
25
+ }
26
+
27
+
28
+ def _now() -> str:
29
+ return datetime.now().astimezone().isoformat()
30
+
31
+
32
+ def _conn():
33
+ nexo_db.init_db()
34
+ return nexo_db.get_db()
35
+
36
+
37
+ def ensure_morning_briefing_runs_table(conn=None) -> None:
38
+ conn = conn or _conn()
39
+ conn.execute(
40
+ """CREATE TABLE IF NOT EXISTS morning_briefing_runs (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ local_date TEXT NOT NULL,
43
+ recipient TEXT NOT NULL,
44
+ status TEXT NOT NULL DEFAULT 'in_progress',
45
+ subject TEXT DEFAULT '',
46
+ body_text TEXT DEFAULT '',
47
+ body_html TEXT DEFAULT '',
48
+ artifact_json TEXT DEFAULT '',
49
+ send_output TEXT DEFAULT '',
50
+ error TEXT DEFAULT '',
51
+ desktop_shown_at TEXT DEFAULT NULL,
52
+ desktop_opened_at TEXT DEFAULT NULL,
53
+ desktop_dismissed_at TEXT DEFAULT NULL,
54
+ started_at TEXT DEFAULT (datetime('now')),
55
+ finished_at TEXT DEFAULT NULL,
56
+ updated_at TEXT DEFAULT (datetime('now')),
57
+ UNIQUE(local_date, recipient)
58
+ )"""
59
+ )
60
+ existing = {
61
+ str(row[1])
62
+ for row in conn.execute("PRAGMA table_info(morning_briefing_runs)").fetchall()
63
+ }
64
+ for name, ddl in PRESENTATION_COLUMNS.items():
65
+ if name not in existing:
66
+ conn.execute(f"ALTER TABLE morning_briefing_runs ADD COLUMN {name} {ddl}")
67
+ conn.execute(
68
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_date "
69
+ "ON morning_briefing_runs(local_date)"
70
+ )
71
+ conn.execute(
72
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_status "
73
+ "ON morning_briefing_runs(status)"
74
+ )
75
+ conn.execute(
76
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_desktop "
77
+ "ON morning_briefing_runs(status, desktop_shown_at, finished_at)"
78
+ )
79
+
80
+
81
+ def _row_to_dict(row) -> dict[str, Any]:
82
+ if row is None:
83
+ return {}
84
+ try:
85
+ return dict(row)
86
+ except Exception:
87
+ return {}
88
+
89
+
90
+ def _artifact_paths() -> dict[str, str]:
91
+ return {
92
+ "markdown": str(LATEST_MARKDOWN_FILE),
93
+ "html": str(LATEST_HTML_FILE),
94
+ "json": str(LATEST_JSON_FILE),
95
+ }
96
+
97
+
98
+ def write_latest_briefing_artifacts(
99
+ *,
100
+ recipient: str,
101
+ subject: str,
102
+ body_text: str,
103
+ body_html: str,
104
+ local_date: str = "",
105
+ run_id: int | None = None,
106
+ ) -> dict[str, Any]:
107
+ generated_at = _now()
108
+ LATEST_MARKDOWN_FILE.parent.mkdir(parents=True, exist_ok=True)
109
+ payload = {
110
+ "schema": "nexo.morning_briefing.v1",
111
+ "generated_at": generated_at,
112
+ "local_date": local_date,
113
+ "run_id": run_id,
114
+ "recipient": recipient,
115
+ "subject": subject,
116
+ "body_text": body_text,
117
+ "body_html": body_html,
118
+ "artifacts": _artifact_paths(),
119
+ }
120
+ markdown = (
121
+ "# Morning briefing\n\n"
122
+ f"- Generated at: {generated_at}\n"
123
+ f"- To: {recipient}\n"
124
+ f"- Subject: {subject}\n\n"
125
+ f"{body_text}\n"
126
+ )
127
+ LATEST_MARKDOWN_FILE.write_text(markdown, encoding="utf-8")
128
+ LATEST_HTML_FILE.write_text(body_html, encoding="utf-8")
129
+ LATEST_JSON_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
130
+ return payload
131
+
132
+
133
+ def mark_morning_briefing_sent(
134
+ *,
135
+ local_date: str,
136
+ recipient: str,
137
+ subject: str,
138
+ body_text: str,
139
+ body_html: str,
140
+ send_output: str = "",
141
+ artifact_payload: dict[str, Any] | None = None,
142
+ ) -> dict[str, Any]:
143
+ conn = _conn()
144
+ ensure_morning_briefing_runs_table(conn)
145
+ now = _now()
146
+ artifact_json = json.dumps(artifact_payload or {}, ensure_ascii=False)
147
+ conn.execute(
148
+ """
149
+ UPDATE morning_briefing_runs
150
+ SET status = 'sent',
151
+ subject = ?,
152
+ body_text = ?,
153
+ body_html = ?,
154
+ artifact_json = ?,
155
+ send_output = ?,
156
+ error = '',
157
+ finished_at = ?,
158
+ updated_at = ?
159
+ WHERE local_date = ? AND recipient = ?
160
+ """,
161
+ (
162
+ str(subject or ""),
163
+ str(body_text or ""),
164
+ str(body_html or ""),
165
+ artifact_json,
166
+ str(send_output or ""),
167
+ now,
168
+ now,
169
+ str(local_date or ""),
170
+ str(recipient or ""),
171
+ ),
172
+ )
173
+ conn.commit()
174
+ return latest_morning_briefing()
175
+
176
+
177
+ def latest_morning_briefing(*, include_non_sent: bool = False) -> dict[str, Any]:
178
+ conn = _conn()
179
+ ensure_morning_briefing_runs_table(conn)
180
+ if include_non_sent:
181
+ row = conn.execute(
182
+ """
183
+ SELECT * FROM morning_briefing_runs
184
+ ORDER BY COALESCE(finished_at, updated_at, started_at) DESC, id DESC
185
+ LIMIT 1
186
+ """
187
+ ).fetchone()
188
+ else:
189
+ row = conn.execute(
190
+ """
191
+ SELECT * FROM morning_briefing_runs
192
+ WHERE status = 'sent'
193
+ ORDER BY COALESCE(finished_at, updated_at, started_at) DESC, id DESC
194
+ LIMIT 1
195
+ """
196
+ ).fetchone()
197
+ payload = public_briefing_payload(_row_to_dict(row))
198
+ return {"ok": True, "briefing": payload}
199
+
200
+
201
+ def public_briefing_payload(row: dict[str, Any]) -> dict[str, Any] | None:
202
+ if not row:
203
+ return None
204
+ artifact_payload: dict[str, Any] = {}
205
+ try:
206
+ parsed = json.loads(row.get("artifact_json") or "{}")
207
+ if isinstance(parsed, dict):
208
+ artifact_payload = parsed
209
+ except Exception:
210
+ artifact_payload = {}
211
+ return {
212
+ "id": row.get("id"),
213
+ "local_date": row.get("local_date") or "",
214
+ "recipient": row.get("recipient") or "",
215
+ "status": row.get("status") or "",
216
+ "subject": row.get("subject") or "",
217
+ "body_text": row.get("body_text") or "",
218
+ "body_html": row.get("body_html") or "",
219
+ "send_output": row.get("send_output") or "",
220
+ "error": row.get("error") or "",
221
+ "started_at": row.get("started_at") or "",
222
+ "finished_at": row.get("finished_at") or "",
223
+ "updated_at": row.get("updated_at") or "",
224
+ "desktop_shown_at": row.get("desktop_shown_at") or "",
225
+ "desktop_opened_at": row.get("desktop_opened_at") or "",
226
+ "desktop_dismissed_at": row.get("desktop_dismissed_at") or "",
227
+ "unseen": not bool(row.get("desktop_shown_at")),
228
+ "artifacts": artifact_payload.get("artifacts") or _artifact_paths(),
229
+ "schema": "nexo.morning_briefing.v1",
230
+ }
231
+
232
+
233
+ def mark_desktop_state(action: str, *, briefing_id: int | None = None) -> dict[str, Any]:
234
+ field_by_action = {
235
+ "shown": "desktop_shown_at",
236
+ "opened": "desktop_opened_at",
237
+ "dismissed": "desktop_dismissed_at",
238
+ }
239
+ field = field_by_action.get(str(action or "").strip().lower())
240
+ if not field:
241
+ return {"ok": False, "error": f"Unknown briefing mark action: {action}"}
242
+ conn = _conn()
243
+ ensure_morning_briefing_runs_table(conn)
244
+ if briefing_id:
245
+ row = _row_to_dict(conn.execute(
246
+ "SELECT * FROM morning_briefing_runs WHERE id = ? LIMIT 1",
247
+ (int(briefing_id),),
248
+ ).fetchone())
249
+ else:
250
+ row = (latest_morning_briefing().get("briefing") or {})
251
+ if row:
252
+ row = _row_to_dict(conn.execute(
253
+ "SELECT * FROM morning_briefing_runs WHERE id = ? LIMIT 1",
254
+ (int(row.get("id") or 0),),
255
+ ).fetchone())
256
+ if not row:
257
+ return {"ok": False, "error": "No morning briefing found."}
258
+ now = _now()
259
+ conn.execute(
260
+ f"UPDATE morning_briefing_runs SET {field} = ?, updated_at = ? WHERE id = ?",
261
+ (now, now, int(row.get("id"))),
262
+ )
263
+ conn.commit()
264
+ updated = _row_to_dict(conn.execute(
265
+ "SELECT * FROM morning_briefing_runs WHERE id = ? LIMIT 1",
266
+ (int(row.get("id")),),
267
+ ).fetchone())
268
+ return {"ok": True, "briefing": public_briefing_payload(updated), "marked": action}
269
+
270
+
271
+ __all__ = [
272
+ "LATEST_HTML_FILE",
273
+ "LATEST_JSON_FILE",
274
+ "LATEST_MARKDOWN_FILE",
275
+ "ensure_morning_briefing_runs_table",
276
+ "latest_morning_briefing",
277
+ "mark_desktop_state",
278
+ "mark_morning_briefing_sent",
279
+ "public_briefing_payload",
280
+ "write_latest_briefing_artifacts",
281
+ ]
@@ -935,6 +935,7 @@ def list_scripts(include_core: bool = False) -> list[dict]:
935
935
  or bool(contract.get("toggleable_core"))
936
936
  )
937
937
  entry["supports_extra_instructions"] = bool(contract.get("supports_extra_instructions"))
938
+ entry["supports_automation_preferences"] = bool(contract.get("supports_automation_preferences"))
938
939
  entry["operator_extra_instructions"] = str(metadata.get("operator_extra_instructions") or "")
939
940
  entry["runtime_contract"] = contract
940
941
  entry["available"] = bool(contract.get("available", True))
@@ -2930,6 +2931,20 @@ def set_automation_instructions(name_or_path: str, instructions: str) -> dict:
2930
2931
  return set_script_extra_instructions(name_or_path, instructions)
2931
2932
 
2932
2933
 
2934
+ def get_automation_preference_contract(name_or_path: str) -> dict:
2935
+ """Return schema + current structured preferences for a product automation."""
2936
+ from automation_preferences import get_automation_preferences
2937
+
2938
+ return get_automation_preferences(name_or_path)
2939
+
2940
+
2941
+ def set_automation_preference_contract(name_or_path: str, payload: dict) -> dict:
2942
+ """Persist structured preferences without touching extra instructions."""
2943
+ from automation_preferences import set_automation_preferences
2944
+
2945
+ return set_automation_preferences(name_or_path, payload)
2946
+
2947
+
2933
2948
  def set_script_schedule_override(
2934
2949
  name_or_path: str,
2935
2950
  *,