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.
@@ -0,0 +1,367 @@
1
+ """Structured operator preferences for product automations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import unicodedata
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ AUTOMATION_PREFERENCES_METADATA_KEY = "automation_preferences"
13
+ SUPPORTED_AUTOMATIONS = {"morning-agent"}
14
+
15
+
16
+ MORNING_AGENT_SCHEMA: dict[str, Any] = {
17
+ "schema_version": 1,
18
+ "automation": "morning-agent",
19
+ "title": "Morning briefing content",
20
+ "groups": [
21
+ {
22
+ "id": "content",
23
+ "label": "Content",
24
+ "items": [
25
+ {
26
+ "id": "priorities",
27
+ "type": "boolean",
28
+ "label": "Priorities",
29
+ "default": True,
30
+ "help": "The most important things NEXO thinks you should look at first.",
31
+ },
32
+ {
33
+ "id": "agenda",
34
+ "type": "boolean",
35
+ "label": "Agenda",
36
+ "default": True,
37
+ "help": "Calendar-like items, dated work and events that affect today.",
38
+ },
39
+ {
40
+ "id": "reminders",
41
+ "type": "boolean",
42
+ "label": "Reminders and tasks",
43
+ "default": True,
44
+ "help": "Pending reminders and tasks saved in NEXO.",
45
+ },
46
+ {
47
+ "id": "followups",
48
+ "type": "boolean",
49
+ "label": "Follow-ups",
50
+ "default": True,
51
+ "help": "Open work that NEXO is tracking until it is resolved or clearly blocked.",
52
+ },
53
+ {
54
+ "id": "decisions",
55
+ "type": "boolean",
56
+ "label": "Recent decisions",
57
+ "default": True,
58
+ "help": "Important decisions recorded recently so they are not forgotten.",
59
+ },
60
+ {
61
+ "id": "email_activity",
62
+ "type": "boolean",
63
+ "label": "Recent sent email",
64
+ "default": True,
65
+ "help": "Emails NEXO sent recently, useful to know what moved while you were away.",
66
+ },
67
+ {
68
+ "id": "blockers",
69
+ "type": "boolean",
70
+ "label": "Blockers and risks",
71
+ "default": True,
72
+ "help": "Things that may stop progress, need your decision or could become a problem.",
73
+ },
74
+ {
75
+ "id": "internal_refs",
76
+ "type": "boolean",
77
+ "label": "Internal references",
78
+ "default": False,
79
+ "help": "Technical file names, IDs or internal references. Keep this off for a cleaner human summary.",
80
+ },
81
+ {
82
+ "id": "news",
83
+ "type": "boolean",
84
+ "label": "News",
85
+ "default": False,
86
+ "help": "A short set of public headlines from the configured news feed. If the feed is unreachable, NEXO simply says it was unavailable.",
87
+ },
88
+ {
89
+ "id": "weather",
90
+ "type": "boolean",
91
+ "label": "Weather",
92
+ "default": True,
93
+ "help": "Today's weather from the location saved in Desktop or your residence in the profile.",
94
+ },
95
+ ],
96
+ },
97
+ {
98
+ "id": "style",
99
+ "label": "Style",
100
+ "items": [
101
+ {
102
+ "id": "length",
103
+ "type": "choice",
104
+ "label": "Length",
105
+ "default": "normal",
106
+ "options": ["short", "normal", "detailed"],
107
+ "help": "How much detail the briefing should include.",
108
+ },
109
+ {
110
+ "id": "tone",
111
+ "type": "choice",
112
+ "label": "Tone",
113
+ "default": "direct",
114
+ "options": ["direct", "warm", "executive", "personal"],
115
+ "help": "How NEXO should write the summary.",
116
+ },
117
+ {
118
+ "id": "format",
119
+ "type": "choice",
120
+ "label": "Format",
121
+ "default": "sections",
122
+ "options": ["sections", "bullets", "narrative"],
123
+ "help": "How the briefing is organized visually.",
124
+ },
125
+ ],
126
+ },
127
+ {
128
+ "id": "delivery",
129
+ "label": "Delivery",
130
+ "items": [
131
+ {
132
+ "id": "quiet_days",
133
+ "type": "choice",
134
+ "label": "Quiet days",
135
+ "default": "summary_if_anything_important",
136
+ "options": ["always_send", "summary_if_anything_important", "skip_if_empty"],
137
+ "help": "What NEXO should do on days with little or no important activity.",
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ }
143
+
144
+
145
+ def _schema_for(name: str) -> dict[str, Any] | None:
146
+ clean = normalize_automation_name(name)
147
+ if clean == "morning-agent":
148
+ return copy.deepcopy(MORNING_AGENT_SCHEMA)
149
+ return None
150
+
151
+
152
+ def normalize_automation_name(name: str) -> str:
153
+ return str(name or "").strip().lower().replace("_", "-")
154
+
155
+
156
+ def supports_automation_preferences(name: str) -> bool:
157
+ return normalize_automation_name(name) in SUPPORTED_AUTOMATIONS
158
+
159
+
160
+ def get_automation_preference_schema(name: str) -> dict[str, Any]:
161
+ schema = _schema_for(name)
162
+ if not schema:
163
+ return {
164
+ "schema_version": 1,
165
+ "automation": normalize_automation_name(name),
166
+ "title": "Automation content",
167
+ "groups": [],
168
+ }
169
+ return schema
170
+
171
+
172
+ def _iter_schema_items(schema: dict[str, Any]):
173
+ for group in list(schema.get("groups") or []):
174
+ for item in list(group.get("items") or []):
175
+ if isinstance(item, dict) and item.get("id"):
176
+ yield item
177
+
178
+
179
+ def default_automation_preferences(name: str) -> dict[str, Any]:
180
+ schema = get_automation_preference_schema(name)
181
+ values: dict[str, Any] = {}
182
+ for item in _iter_schema_items(schema):
183
+ values[str(item["id"])] = copy.deepcopy(item.get("default"))
184
+ return {
185
+ "schema_version": int(schema.get("schema_version") or 1),
186
+ "automation": normalize_automation_name(name),
187
+ "values": values,
188
+ }
189
+
190
+
191
+ def validate_automation_preferences(name: str, payload: dict[str, Any] | None) -> dict[str, Any]:
192
+ schema = get_automation_preference_schema(name)
193
+ defaults = default_automation_preferences(name)
194
+ source_values = {}
195
+ if isinstance(payload, dict):
196
+ if isinstance(payload.get("values"), dict):
197
+ source_values = payload.get("values") or {}
198
+ else:
199
+ source_values = payload
200
+ values = dict(defaults["values"])
201
+ warnings: list[str] = []
202
+ for item in _iter_schema_items(schema):
203
+ key = str(item["id"])
204
+ if key not in source_values:
205
+ continue
206
+ if item.get("disabled"):
207
+ values[key] = copy.deepcopy(item.get("default"))
208
+ warnings.append(f"{key}: disabled")
209
+ continue
210
+ kind = str(item.get("type") or "text")
211
+ raw_value = source_values.get(key)
212
+ if kind == "boolean":
213
+ values[key] = bool(raw_value)
214
+ elif kind == "choice":
215
+ options = [str(v) for v in list(item.get("options") or [])]
216
+ clean = str(raw_value or "").strip()
217
+ if clean in options:
218
+ values[key] = clean
219
+ else:
220
+ warnings.append(f"{key}: invalid choice")
221
+ elif kind == "number":
222
+ try:
223
+ values[key] = int(raw_value)
224
+ except Exception:
225
+ warnings.append(f"{key}: invalid number")
226
+ else:
227
+ values[key] = str(raw_value or "").strip()[:1000]
228
+ return {
229
+ "schema_version": int(schema.get("schema_version") or 1),
230
+ "automation": normalize_automation_name(name),
231
+ "values": values,
232
+ "warnings": warnings,
233
+ }
234
+
235
+
236
+ def _script_row_for(name_or_path: str) -> tuple[dict, dict] | tuple[None, None]:
237
+ from db import init_db
238
+ from db._personal_scripts import get_personal_script
239
+ from script_registry import resolve_script, sync_personal_scripts
240
+
241
+ init_db()
242
+ sync_personal_scripts()
243
+ script = get_personal_script(name_or_path, include_core=True) or resolve_script(name_or_path)
244
+ if not script and normalize_automation_name(name_or_path) == "morning-agent":
245
+ script_path = Path(__file__).resolve().parent / "scripts" / "nexo-morning-agent.py"
246
+ script = {
247
+ "name": "morning-agent",
248
+ "path": str(script_path),
249
+ "description": "Generate and send the operator's daily morning briefing email.",
250
+ "runtime": "python",
251
+ "core": True,
252
+ "metadata": {},
253
+ "origin": "core",
254
+ }
255
+ if not script:
256
+ return None, None
257
+ existing = get_personal_script(script.get("path", ""), include_core=True) or script
258
+ return script, existing
259
+
260
+
261
+ def get_automation_preferences(name_or_path: str) -> dict[str, Any]:
262
+ clean_name = normalize_automation_name(name_or_path)
263
+ script, existing = _script_row_for(name_or_path)
264
+ if script:
265
+ clean_name = normalize_automation_name(script.get("name") or clean_name)
266
+ metadata = (existing or {}).get("metadata") if isinstance((existing or {}).get("metadata"), dict) else {}
267
+ stored = metadata.get(AUTOMATION_PREFERENCES_METADATA_KEY) if isinstance(metadata, dict) else {}
268
+ validated = validate_automation_preferences(clean_name, stored if isinstance(stored, dict) else {})
269
+ return {
270
+ "ok": True,
271
+ "name": clean_name,
272
+ "schema": get_automation_preference_schema(clean_name),
273
+ "preferences": validated,
274
+ "supports_automation_preferences": supports_automation_preferences(clean_name),
275
+ }
276
+
277
+
278
+ def set_automation_preferences(name_or_path: str, payload: dict[str, Any]) -> dict[str, Any]:
279
+ from db._personal_scripts import upsert_personal_script
280
+
281
+ script, existing = _script_row_for(name_or_path)
282
+ if not script:
283
+ return {"ok": False, "error": f"Automation not found: {name_or_path}"}
284
+ clean_name = normalize_automation_name(script.get("name") or name_or_path)
285
+ if not supports_automation_preferences(clean_name):
286
+ return {"ok": False, "error": "This automation does not support structured preferences."}
287
+ validated = validate_automation_preferences(clean_name, payload)
288
+ metadata = dict((existing or script).get("metadata") or {})
289
+ metadata[AUTOMATION_PREFERENCES_METADATA_KEY] = {
290
+ "schema_version": validated["schema_version"],
291
+ "values": validated["values"],
292
+ }
293
+ script_origin = "core" if (bool(script.get("core")) or str(script.get("origin") or "") == "core") else "user"
294
+ upsert_personal_script(
295
+ name=script.get("name", clean_name),
296
+ path=script.get("path", ""),
297
+ description=script.get("description", ""),
298
+ runtime=script.get("runtime", "unknown"),
299
+ metadata=metadata,
300
+ created_by="nexo-core" if script_origin == "core" else "manual",
301
+ source="core-toggle" if script_origin == "core" else "filesystem",
302
+ origin=script_origin,
303
+ enabled=bool((existing or script).get("enabled", True)),
304
+ has_inline_metadata=bool(script.get("metadata")),
305
+ )
306
+ return {
307
+ "ok": True,
308
+ "name": clean_name,
309
+ "preferences": validated,
310
+ "supports_automation_preferences": True,
311
+ }
312
+
313
+
314
+ def search_automation_preference_schema(name: str, query: str) -> list[dict[str, Any]]:
315
+ clean_query = _fold_text(query)
316
+ if not clean_query:
317
+ return []
318
+ matches: list[dict[str, Any]] = []
319
+ for group in list(get_automation_preference_schema(name).get("groups") or []):
320
+ for item in list(group.get("items") or []):
321
+ text = " ".join([
322
+ str(item.get("id") or ""),
323
+ str(item.get("label") or ""),
324
+ str(item.get("disabled_reason") or ""),
325
+ str(item.get("help") or ""),
326
+ str(group.get("label") or ""),
327
+ ])
328
+ if clean_query in _fold_text(text):
329
+ matches.append({"group": group.get("id"), **item})
330
+ return matches
331
+
332
+
333
+ def _fold_text(value: str) -> str:
334
+ normalized = unicodedata.normalize("NFKD", str(value or ""))
335
+ asciiish = "".join(ch for ch in normalized if not unicodedata.combining(ch))
336
+ return asciiish.casefold()
337
+
338
+
339
+ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
340
+ result = get_automation_preferences(name_or_path)
341
+ if not result.get("supports_automation_preferences"):
342
+ return ""
343
+ prefs = result.get("preferences") or {}
344
+ values = prefs.get("values") if isinstance(prefs, dict) else {}
345
+ if not isinstance(values, dict):
346
+ values = {}
347
+ compact = json.dumps(values, ensure_ascii=False, sort_keys=True)
348
+ return (
349
+ "\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
350
+ f"{compact}\n"
351
+ "Use these preferences to decide what to include, omit, and emphasize. "
352
+ "Disabled/unavailable data sources must not be invented.\n"
353
+ )
354
+
355
+
356
+ __all__ = [
357
+ "AUTOMATION_PREFERENCES_METADATA_KEY",
358
+ "default_automation_preferences",
359
+ "format_automation_preferences_prompt_block",
360
+ "get_automation_preference_schema",
361
+ "get_automation_preferences",
362
+ "normalize_automation_name",
363
+ "search_automation_preference_schema",
364
+ "set_automation_preferences",
365
+ "supports_automation_preferences",
366
+ "validate_automation_preferences",
367
+ ]
package/src/cli.py CHANGED
@@ -1037,11 +1037,19 @@ def _automations_set_schedule(args):
1037
1037
  interval_seconds = int(args.every_seconds)
1038
1038
  elif getattr(args, "daily_at", None):
1039
1039
  daily_at = str(args.daily_at).strip()
1040
+ if getattr(args, "weekdays", None) and not daily_at:
1041
+ result = {"ok": False, "error": "--weekdays can only be used with --daily-at"}
1042
+ if args.json:
1043
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1044
+ return 1
1045
+ print(result["error"], file=sys.stderr)
1046
+ return 1
1040
1047
 
1041
1048
  result = set_automation_schedule(
1042
1049
  args.name,
1043
1050
  interval_seconds=interval_seconds,
1044
1051
  daily_at=daily_at,
1052
+ weekdays=getattr(args, "weekdays", None),
1045
1053
  clear=bool(getattr(args, "reset", False)),
1046
1054
  )
1047
1055
  if args.json:
@@ -1059,6 +1067,89 @@ def _automations_set_schedule(args):
1059
1067
  return 0
1060
1068
 
1061
1069
 
1070
+ def _automations_preference_schema(args):
1071
+ from automation_preferences import get_automation_preference_schema, search_automation_preference_schema
1072
+
1073
+ query = str(getattr(args, "query", "") or "").strip()
1074
+ schema = get_automation_preference_schema(args.name)
1075
+ payload = {
1076
+ "ok": True,
1077
+ "name": args.name,
1078
+ "schema": schema,
1079
+ "matches": search_automation_preference_schema(args.name, query) if query else [],
1080
+ }
1081
+ if args.json:
1082
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1083
+ return 0
1084
+ print(schema.get("title") or args.name)
1085
+ for group in list(schema.get("groups") or []):
1086
+ print(f"\n{group.get('label') or group.get('id')}")
1087
+ for item in list(group.get("items") or []):
1088
+ marker = " (unavailable)" if item.get("disabled") else ""
1089
+ print(f" - {item.get('label') or item.get('id')}{marker}")
1090
+ return 0
1091
+
1092
+
1093
+ def _automations_preferences(args):
1094
+ from script_registry import get_automation_preference_contract, set_automation_preference_contract
1095
+
1096
+ has_payload = any([
1097
+ str(getattr(args, "payload", "") or "").strip(),
1098
+ str(getattr(args, "payload_file", "") or "").strip(),
1099
+ bool(getattr(args, "payload_stdin", False)),
1100
+ ])
1101
+ if has_payload:
1102
+ payload, error_code = _load_json_payload_arg(args, command_name="automations preferences")
1103
+ if error_code is not None:
1104
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1105
+ return error_code
1106
+ result = set_automation_preference_contract(args.name, payload)
1107
+ else:
1108
+ result = get_automation_preference_contract(args.name)
1109
+ if args.json:
1110
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1111
+ return 0 if result.get("ok", True) else 1
1112
+ if not result.get("ok", True):
1113
+ print(result.get("error", "Could not update automation preferences"), file=sys.stderr)
1114
+ return 1
1115
+ prefs = result.get("preferences") or {}
1116
+ values = prefs.get("values") if isinstance(prefs, dict) else {}
1117
+ print(f"Content preferences for {result.get('name') or args.name}:")
1118
+ for key, value in sorted((values or {}).items()):
1119
+ print(f" {key}: {value}")
1120
+ return 0
1121
+
1122
+
1123
+ def _morning_briefing(args):
1124
+ from morning_briefing import latest_morning_briefing, mark_desktop_state
1125
+
1126
+ command = str(getattr(args, "morning_briefing_command", "") or "").strip()
1127
+ if command == "latest":
1128
+ result = latest_morning_briefing(include_non_sent=bool(getattr(args, "include_non_sent", False)))
1129
+ elif command in {"mark-shown", "mark-opened", "mark-dismissed"}:
1130
+ action = command.replace("mark-", "")
1131
+ result = mark_desktop_state(action, briefing_id=getattr(args, "briefing_id", None))
1132
+ else:
1133
+ result = {"ok": False, "error": "Unknown morning briefing command."}
1134
+
1135
+ if getattr(args, "json", False):
1136
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1137
+ return 0 if result.get("ok", True) else 1
1138
+ if not result.get("ok", True):
1139
+ print(result.get("error", "Could not read morning briefing"), file=sys.stderr)
1140
+ return 1
1141
+ briefing = result.get("briefing")
1142
+ if not briefing:
1143
+ print("No morning briefing is available yet.")
1144
+ return 0
1145
+ print(briefing.get("subject") or "Morning briefing")
1146
+ body = str(briefing.get("body_text") or "").strip()
1147
+ if body:
1148
+ print()
1149
+ print(body)
1150
+ return 0
1151
+
1152
+
1062
1153
  def _agents_list(args):
1063
1154
  from script_registry import list_agents
1064
1155
 
@@ -2529,6 +2620,28 @@ def _preferences(args):
2529
2620
  _load_user_default_resonance,
2530
2621
  )
2531
2622
 
2623
+ action = str(getattr(args, "preferences_action", "") or "").strip().lower()
2624
+ if action in {"catalog", "get", "set", "explain"}:
2625
+ from preference_catalog import build_preference_catalog, explain_preference, set_preference
2626
+
2627
+ if action == "catalog":
2628
+ payload = build_preference_catalog(
2629
+ include_values=bool(getattr(args, "include_values", False)),
2630
+ query=str(getattr(args, "query", "") or "").strip() or None,
2631
+ )
2632
+ elif action in {"get", "explain"}:
2633
+ pref_id = str(getattr(args, "preferences_id", "") or "").strip()
2634
+ payload = explain_preference(pref_id)
2635
+ else:
2636
+ pref_id = str(getattr(args, "preferences_id", "") or "").strip()
2637
+ value = getattr(args, "preferences_value", None)
2638
+ payload = set_preference(pref_id, value, dry_run=bool(getattr(args, "dry_run", False)))
2639
+ if getattr(args, "json", False):
2640
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2641
+ else:
2642
+ print(json.dumps(payload, ensure_ascii=False))
2643
+ return 0 if payload.get("ok", True) else 1
2644
+
2532
2645
  prefs = load_client_preferences()
2533
2646
  if not isinstance(prefs, dict):
2534
2647
  prefs = {}
@@ -4067,8 +4180,37 @@ def main():
4067
4180
  automations_schedule_group.add_argument("--every-seconds", type=int, help="Run the automation every N seconds")
4068
4181
  automations_schedule_group.add_argument("--daily-at", type=str, help="Run the automation every day at HH:MM (24h)")
4069
4182
  automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
4183
+ automations_schedule_p.add_argument("--weekdays", default=None, help="Optional calendar days, e.g. Mon-Fri or Tue,Sat")
4070
4184
  automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
4071
4185
 
4186
+ automations_schema_p = automations_sub.add_parser(
4187
+ "preference-schema",
4188
+ help="Read structured content options for an automation",
4189
+ )
4190
+ automations_schema_p.add_argument("name", help="Automation name or path")
4191
+ automations_schema_p.add_argument("--query", default="", help="Local search inside the preference options")
4192
+ automations_schema_p.add_argument("--json", action="store_true", help="JSON output")
4193
+
4194
+ automations_preferences_p = automations_sub.add_parser(
4195
+ "preferences",
4196
+ help="Read or update structured content preferences for an automation",
4197
+ )
4198
+ automations_preferences_p.add_argument("name", help="Automation name or path")
4199
+ automations_preferences_p.add_argument("--payload", default="", help="JSON object to persist")
4200
+ automations_preferences_p.add_argument("--payload-file", default="", help="Path to a JSON object to persist")
4201
+ automations_preferences_p.add_argument("--payload-stdin", action="store_true", help="Read JSON object from stdin")
4202
+ automations_preferences_p.add_argument("--json", action="store_true", help="JSON output")
4203
+
4204
+ morning_briefing_parser = sub.add_parser("morning-briefing", help="Read the latest operator morning briefing")
4205
+ morning_briefing_sub = morning_briefing_parser.add_subparsers(dest="morning_briefing_command")
4206
+ morning_latest_p = morning_briefing_sub.add_parser("latest", help="Show the latest sent morning briefing")
4207
+ morning_latest_p.add_argument("--include-non-sent", action="store_true", help="Include failed/in-progress rows for diagnostics")
4208
+ morning_latest_p.add_argument("--json", action="store_true", help="JSON output")
4209
+ for mark_command in ("mark-shown", "mark-opened", "mark-dismissed"):
4210
+ mark_p = morning_briefing_sub.add_parser(mark_command, help=f"{mark_command.replace('-', ' ')} for Desktop")
4211
+ mark_p.add_argument("--id", dest="briefing_id", type=int, default=None, help="Specific briefing id")
4212
+ mark_p.add_argument("--json", action="store_true", help="JSON output")
4213
+
4072
4214
  core_schedules_parser = sub.add_parser("core-schedules", help="Manage structural core cron cadences")
4073
4215
  core_schedules_sub = core_schedules_parser.add_subparsers(dest="core_schedules_command")
4074
4216
 
@@ -4170,6 +4312,12 @@ def main():
4170
4312
  "preferences",
4171
4313
  help="Read or change NEXO user preferences (resonance, default client, ...)",
4172
4314
  )
4315
+ preferences_parser.add_argument("preferences_action", nargs="?", choices=["catalog", "get", "set", "explain"], help="Catalog operation")
4316
+ preferences_parser.add_argument("preferences_id", nargs="?", help="Preference id or alias")
4317
+ preferences_parser.add_argument("preferences_value", nargs="?", help="Value for `preferences set`")
4318
+ preferences_parser.add_argument("--query", default="", help="Filter catalog entries")
4319
+ preferences_parser.add_argument("--include-values", action="store_true", help="Include current values in catalog output")
4320
+ preferences_parser.add_argument("--dry-run", action="store_true", help="Preview a preference change")
4173
4321
  preferences_parser.add_argument(
4174
4322
  "--resonance",
4175
4323
  choices=["maximo", "alto", "medio", "bajo"],
@@ -4658,8 +4806,17 @@ def main():
4658
4806
  return _automations_set_instructions(args)
4659
4807
  elif args.automations_command == "schedule":
4660
4808
  return _automations_set_schedule(args)
4809
+ elif args.automations_command == "preference-schema":
4810
+ return _automations_preference_schema(args)
4811
+ elif args.automations_command == "preferences":
4812
+ return _automations_preferences(args)
4661
4813
  automations_parser.print_help()
4662
4814
  return 0
4815
+ elif args.command == "morning-briefing":
4816
+ if args.morning_briefing_command in {"latest", "mark-shown", "mark-opened", "mark-dismissed"}:
4817
+ return _morning_briefing(args)
4818
+ morning_briefing_parser.print_help()
4819
+ return 0
4663
4820
  elif args.command == "core-schedules":
4664
4821
  if args.core_schedules_command == "list":
4665
4822
  return _core_schedules_list(args)