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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.28.0",
3
+ "version": "7.29.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.28.0` is the current packaged-runtime line. Minor release over v7.27.6 - the Brain memory architecture now links action authorship, reasons, operational state, entity profiles, failure prevention, semantic layers, and release-gated runtime memory benchmarks.
21
+ Version `7.29.0` is the current packaged-runtime line. Minor release over v7.28.0 - the morning briefing now has structured content preferences, Desktop-facing briefing presentation state, and per-account email signature support.
22
+
23
+ Previously in `7.28.0`: minor release over v7.27.6 - the Brain memory architecture now links action authorship, reasons, operational state, entity profiles, failure prevention, semantic layers, and release-gated runtime memory benchmarks.
22
24
 
23
25
  Previously in `7.27.6`: patch release over v7.27.5 - operational memory continuity now persists promises as commitments, routes pre-answer questions through evidence-backed memory, and exposes observation-queue convergence in health checks.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.28.0",
3
+ "version": "7.29.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -672,10 +672,17 @@ def get_script_runtime_contract(name: str) -> dict[str, Any]:
672
672
  blocked_reason = str(recipient_status.get("reason") or "")
673
673
  blocked_reason_code = str(recipient_status.get("reason_code") or "")
674
674
 
675
+ try:
676
+ from automation_preferences import supports_automation_preferences
677
+ preferences_supported = supports_automation_preferences(clean_name)
678
+ except Exception:
679
+ preferences_supported = False
680
+
675
681
  return {
676
682
  "name": clean_name,
677
683
  "toggleable_core": is_toggleable_core_script(clean_name),
678
684
  "supports_extra_instructions": supports_operator_extra_instructions(clean_name),
685
+ "supports_automation_preferences": preferences_supported,
679
686
  "schedule_configurable": bool(schedule_state.get("schedule_configurable")),
680
687
  "schedule_type": str(schedule_state.get("schedule_type") or ""),
681
688
  "schedule_source": str(schedule_state.get("schedule_source") or ""),
@@ -0,0 +1,323 @@
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
+ {"id": "priorities", "type": "boolean", "label": "Priorities", "default": True},
26
+ {"id": "agenda", "type": "boolean", "label": "Agenda", "default": True},
27
+ {"id": "reminders", "type": "boolean", "label": "Reminders and tasks", "default": True},
28
+ {"id": "followups", "type": "boolean", "label": "Follow-ups", "default": True},
29
+ {"id": "decisions", "type": "boolean", "label": "Recent decisions", "default": True},
30
+ {"id": "email_activity", "type": "boolean", "label": "Recent sent email", "default": True},
31
+ {"id": "blockers", "type": "boolean", "label": "Blockers and risks", "default": True},
32
+ {"id": "internal_refs", "type": "boolean", "label": "Internal references", "default": False},
33
+ {
34
+ "id": "news",
35
+ "type": "boolean",
36
+ "label": "News",
37
+ "default": False,
38
+ "disabled": True,
39
+ "disabled_reason": "No verified news source is connected yet.",
40
+ },
41
+ {
42
+ "id": "weather",
43
+ "type": "boolean",
44
+ "label": "Weather",
45
+ "default": False,
46
+ "disabled": True,
47
+ "disabled_reason": "No verified weather source is connected yet.",
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ "id": "style",
53
+ "label": "Style",
54
+ "items": [
55
+ {
56
+ "id": "length",
57
+ "type": "choice",
58
+ "label": "Length",
59
+ "default": "normal",
60
+ "options": ["short", "normal", "detailed"],
61
+ },
62
+ {
63
+ "id": "tone",
64
+ "type": "choice",
65
+ "label": "Tone",
66
+ "default": "direct",
67
+ "options": ["direct", "warm", "executive", "personal"],
68
+ },
69
+ {
70
+ "id": "format",
71
+ "type": "choice",
72
+ "label": "Format",
73
+ "default": "sections",
74
+ "options": ["sections", "bullets", "narrative"],
75
+ },
76
+ ],
77
+ },
78
+ {
79
+ "id": "delivery",
80
+ "label": "Delivery",
81
+ "items": [
82
+ {
83
+ "id": "quiet_days",
84
+ "type": "choice",
85
+ "label": "Quiet days",
86
+ "default": "summary_if_anything_important",
87
+ "options": ["always_send", "summary_if_anything_important", "skip_if_empty"],
88
+ },
89
+ {
90
+ "id": "audience",
91
+ "type": "choice",
92
+ "label": "User type",
93
+ "default": "operator",
94
+ "options": ["operator", "founder", "student", "sales", "technical", "personal"],
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ }
100
+
101
+
102
+ def _schema_for(name: str) -> dict[str, Any] | None:
103
+ clean = normalize_automation_name(name)
104
+ if clean == "morning-agent":
105
+ return copy.deepcopy(MORNING_AGENT_SCHEMA)
106
+ return None
107
+
108
+
109
+ def normalize_automation_name(name: str) -> str:
110
+ return str(name or "").strip().lower().replace("_", "-")
111
+
112
+
113
+ def supports_automation_preferences(name: str) -> bool:
114
+ return normalize_automation_name(name) in SUPPORTED_AUTOMATIONS
115
+
116
+
117
+ def get_automation_preference_schema(name: str) -> dict[str, Any]:
118
+ schema = _schema_for(name)
119
+ if not schema:
120
+ return {
121
+ "schema_version": 1,
122
+ "automation": normalize_automation_name(name),
123
+ "title": "Automation content",
124
+ "groups": [],
125
+ }
126
+ return schema
127
+
128
+
129
+ def _iter_schema_items(schema: dict[str, Any]):
130
+ for group in list(schema.get("groups") or []):
131
+ for item in list(group.get("items") or []):
132
+ if isinstance(item, dict) and item.get("id"):
133
+ yield item
134
+
135
+
136
+ def default_automation_preferences(name: str) -> dict[str, Any]:
137
+ schema = get_automation_preference_schema(name)
138
+ values: dict[str, Any] = {}
139
+ for item in _iter_schema_items(schema):
140
+ values[str(item["id"])] = copy.deepcopy(item.get("default"))
141
+ return {
142
+ "schema_version": int(schema.get("schema_version") or 1),
143
+ "automation": normalize_automation_name(name),
144
+ "values": values,
145
+ }
146
+
147
+
148
+ def validate_automation_preferences(name: str, payload: dict[str, Any] | None) -> dict[str, Any]:
149
+ schema = get_automation_preference_schema(name)
150
+ defaults = default_automation_preferences(name)
151
+ source_values = {}
152
+ if isinstance(payload, dict):
153
+ if isinstance(payload.get("values"), dict):
154
+ source_values = payload.get("values") or {}
155
+ else:
156
+ source_values = payload
157
+ values = dict(defaults["values"])
158
+ warnings: list[str] = []
159
+ for item in _iter_schema_items(schema):
160
+ key = str(item["id"])
161
+ if key not in source_values:
162
+ continue
163
+ if item.get("disabled"):
164
+ values[key] = copy.deepcopy(item.get("default"))
165
+ warnings.append(f"{key}: disabled")
166
+ continue
167
+ kind = str(item.get("type") or "text")
168
+ raw_value = source_values.get(key)
169
+ if kind == "boolean":
170
+ values[key] = bool(raw_value)
171
+ elif kind == "choice":
172
+ options = [str(v) for v in list(item.get("options") or [])]
173
+ clean = str(raw_value or "").strip()
174
+ if clean in options:
175
+ values[key] = clean
176
+ else:
177
+ warnings.append(f"{key}: invalid choice")
178
+ elif kind == "number":
179
+ try:
180
+ values[key] = int(raw_value)
181
+ except Exception:
182
+ warnings.append(f"{key}: invalid number")
183
+ else:
184
+ values[key] = str(raw_value or "").strip()[:1000]
185
+ return {
186
+ "schema_version": int(schema.get("schema_version") or 1),
187
+ "automation": normalize_automation_name(name),
188
+ "values": values,
189
+ "warnings": warnings,
190
+ }
191
+
192
+
193
+ def _script_row_for(name_or_path: str) -> tuple[dict, dict] | tuple[None, None]:
194
+ from db import init_db
195
+ from db._personal_scripts import get_personal_script
196
+ from script_registry import resolve_script, sync_personal_scripts
197
+
198
+ init_db()
199
+ sync_personal_scripts()
200
+ script = get_personal_script(name_or_path, include_core=True) or resolve_script(name_or_path)
201
+ if not script and normalize_automation_name(name_or_path) == "morning-agent":
202
+ script_path = Path(__file__).resolve().parent / "scripts" / "nexo-morning-agent.py"
203
+ script = {
204
+ "name": "morning-agent",
205
+ "path": str(script_path),
206
+ "description": "Generate and send the operator's daily morning briefing email.",
207
+ "runtime": "python",
208
+ "core": True,
209
+ "metadata": {},
210
+ "origin": "core",
211
+ }
212
+ if not script:
213
+ return None, None
214
+ existing = get_personal_script(script.get("path", ""), include_core=True) or script
215
+ return script, existing
216
+
217
+
218
+ def get_automation_preferences(name_or_path: str) -> dict[str, Any]:
219
+ clean_name = normalize_automation_name(name_or_path)
220
+ script, existing = _script_row_for(name_or_path)
221
+ if script:
222
+ clean_name = normalize_automation_name(script.get("name") or clean_name)
223
+ metadata = (existing or {}).get("metadata") if isinstance((existing or {}).get("metadata"), dict) else {}
224
+ stored = metadata.get(AUTOMATION_PREFERENCES_METADATA_KEY) if isinstance(metadata, dict) else {}
225
+ validated = validate_automation_preferences(clean_name, stored if isinstance(stored, dict) else {})
226
+ return {
227
+ "ok": True,
228
+ "name": clean_name,
229
+ "schema": get_automation_preference_schema(clean_name),
230
+ "preferences": validated,
231
+ "supports_automation_preferences": supports_automation_preferences(clean_name),
232
+ }
233
+
234
+
235
+ def set_automation_preferences(name_or_path: str, payload: dict[str, Any]) -> dict[str, Any]:
236
+ from db._personal_scripts import upsert_personal_script
237
+
238
+ script, existing = _script_row_for(name_or_path)
239
+ if not script:
240
+ return {"ok": False, "error": f"Automation not found: {name_or_path}"}
241
+ clean_name = normalize_automation_name(script.get("name") or name_or_path)
242
+ if not supports_automation_preferences(clean_name):
243
+ return {"ok": False, "error": "This automation does not support structured preferences."}
244
+ validated = validate_automation_preferences(clean_name, payload)
245
+ metadata = dict((existing or script).get("metadata") or {})
246
+ metadata[AUTOMATION_PREFERENCES_METADATA_KEY] = {
247
+ "schema_version": validated["schema_version"],
248
+ "values": validated["values"],
249
+ }
250
+ script_origin = "core" if (bool(script.get("core")) or str(script.get("origin") or "") == "core") else "user"
251
+ upsert_personal_script(
252
+ name=script.get("name", clean_name),
253
+ path=script.get("path", ""),
254
+ description=script.get("description", ""),
255
+ runtime=script.get("runtime", "unknown"),
256
+ metadata=metadata,
257
+ created_by="nexo-core" if script_origin == "core" else "manual",
258
+ source="core-toggle" if script_origin == "core" else "filesystem",
259
+ origin=script_origin,
260
+ enabled=bool((existing or script).get("enabled", True)),
261
+ has_inline_metadata=bool(script.get("metadata")),
262
+ )
263
+ return {
264
+ "ok": True,
265
+ "name": clean_name,
266
+ "preferences": validated,
267
+ "supports_automation_preferences": True,
268
+ }
269
+
270
+
271
+ def search_automation_preference_schema(name: str, query: str) -> list[dict[str, Any]]:
272
+ clean_query = _fold_text(query)
273
+ if not clean_query:
274
+ return []
275
+ matches: list[dict[str, Any]] = []
276
+ for group in list(get_automation_preference_schema(name).get("groups") or []):
277
+ for item in list(group.get("items") or []):
278
+ text = " ".join([
279
+ str(item.get("id") or ""),
280
+ str(item.get("label") or ""),
281
+ str(item.get("disabled_reason") or ""),
282
+ str(group.get("label") or ""),
283
+ ])
284
+ if clean_query in _fold_text(text):
285
+ matches.append({"group": group.get("id"), **item})
286
+ return matches
287
+
288
+
289
+ def _fold_text(value: str) -> str:
290
+ normalized = unicodedata.normalize("NFKD", str(value or ""))
291
+ asciiish = "".join(ch for ch in normalized if not unicodedata.combining(ch))
292
+ return asciiish.casefold()
293
+
294
+
295
+ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
296
+ result = get_automation_preferences(name_or_path)
297
+ if not result.get("supports_automation_preferences"):
298
+ return ""
299
+ prefs = result.get("preferences") or {}
300
+ values = prefs.get("values") if isinstance(prefs, dict) else {}
301
+ if not isinstance(values, dict):
302
+ values = {}
303
+ compact = json.dumps(values, ensure_ascii=False, sort_keys=True)
304
+ return (
305
+ "\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
306
+ f"{compact}\n"
307
+ "Use these preferences to decide what to include, omit, and emphasize. "
308
+ "Disabled/unavailable data sources must not be invented.\n"
309
+ )
310
+
311
+
312
+ __all__ = [
313
+ "AUTOMATION_PREFERENCES_METADATA_KEY",
314
+ "default_automation_preferences",
315
+ "format_automation_preferences_prompt_block",
316
+ "get_automation_preference_schema",
317
+ "get_automation_preferences",
318
+ "normalize_automation_name",
319
+ "search_automation_preference_schema",
320
+ "set_automation_preferences",
321
+ "supports_automation_preferences",
322
+ "validate_automation_preferences",
323
+ ]
package/src/cli.py CHANGED
@@ -1059,6 +1059,89 @@ def _automations_set_schedule(args):
1059
1059
  return 0
1060
1060
 
1061
1061
 
1062
+ def _automations_preference_schema(args):
1063
+ from automation_preferences import get_automation_preference_schema, search_automation_preference_schema
1064
+
1065
+ query = str(getattr(args, "query", "") or "").strip()
1066
+ schema = get_automation_preference_schema(args.name)
1067
+ payload = {
1068
+ "ok": True,
1069
+ "name": args.name,
1070
+ "schema": schema,
1071
+ "matches": search_automation_preference_schema(args.name, query) if query else [],
1072
+ }
1073
+ if args.json:
1074
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1075
+ return 0
1076
+ print(schema.get("title") or args.name)
1077
+ for group in list(schema.get("groups") or []):
1078
+ print(f"\n{group.get('label') or group.get('id')}")
1079
+ for item in list(group.get("items") or []):
1080
+ marker = " (unavailable)" if item.get("disabled") else ""
1081
+ print(f" - {item.get('label') or item.get('id')}{marker}")
1082
+ return 0
1083
+
1084
+
1085
+ def _automations_preferences(args):
1086
+ from script_registry import get_automation_preference_contract, set_automation_preference_contract
1087
+
1088
+ has_payload = any([
1089
+ str(getattr(args, "payload", "") or "").strip(),
1090
+ str(getattr(args, "payload_file", "") or "").strip(),
1091
+ bool(getattr(args, "payload_stdin", False)),
1092
+ ])
1093
+ if has_payload:
1094
+ payload, error_code = _load_json_payload_arg(args, command_name="automations preferences")
1095
+ if error_code is not None:
1096
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1097
+ return error_code
1098
+ result = set_automation_preference_contract(args.name, payload)
1099
+ else:
1100
+ result = get_automation_preference_contract(args.name)
1101
+ if args.json:
1102
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1103
+ return 0 if result.get("ok", True) else 1
1104
+ if not result.get("ok", True):
1105
+ print(result.get("error", "Could not update automation preferences"), file=sys.stderr)
1106
+ return 1
1107
+ prefs = result.get("preferences") or {}
1108
+ values = prefs.get("values") if isinstance(prefs, dict) else {}
1109
+ print(f"Content preferences for {result.get('name') or args.name}:")
1110
+ for key, value in sorted((values or {}).items()):
1111
+ print(f" {key}: {value}")
1112
+ return 0
1113
+
1114
+
1115
+ def _morning_briefing(args):
1116
+ from morning_briefing import latest_morning_briefing, mark_desktop_state
1117
+
1118
+ command = str(getattr(args, "morning_briefing_command", "") or "").strip()
1119
+ if command == "latest":
1120
+ result = latest_morning_briefing(include_non_sent=bool(getattr(args, "include_non_sent", False)))
1121
+ elif command in {"mark-shown", "mark-opened", "mark-dismissed"}:
1122
+ action = command.replace("mark-", "")
1123
+ result = mark_desktop_state(action, briefing_id=getattr(args, "briefing_id", None))
1124
+ else:
1125
+ result = {"ok": False, "error": "Unknown morning briefing command."}
1126
+
1127
+ if getattr(args, "json", False):
1128
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1129
+ return 0 if result.get("ok", True) else 1
1130
+ if not result.get("ok", True):
1131
+ print(result.get("error", "Could not read morning briefing"), file=sys.stderr)
1132
+ return 1
1133
+ briefing = result.get("briefing")
1134
+ if not briefing:
1135
+ print("No morning briefing is available yet.")
1136
+ return 0
1137
+ print(briefing.get("subject") or "Morning briefing")
1138
+ body = str(briefing.get("body_text") or "").strip()
1139
+ if body:
1140
+ print()
1141
+ print(body)
1142
+ return 0
1143
+
1144
+
1062
1145
  def _agents_list(args):
1063
1146
  from script_registry import list_agents
1064
1147
 
@@ -4069,6 +4152,34 @@ def main():
4069
4152
  automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
4070
4153
  automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
4071
4154
 
4155
+ automations_schema_p = automations_sub.add_parser(
4156
+ "preference-schema",
4157
+ help="Read structured content options for an automation",
4158
+ )
4159
+ automations_schema_p.add_argument("name", help="Automation name or path")
4160
+ automations_schema_p.add_argument("--query", default="", help="Local search inside the preference options")
4161
+ automations_schema_p.add_argument("--json", action="store_true", help="JSON output")
4162
+
4163
+ automations_preferences_p = automations_sub.add_parser(
4164
+ "preferences",
4165
+ help="Read or update structured content preferences for an automation",
4166
+ )
4167
+ automations_preferences_p.add_argument("name", help="Automation name or path")
4168
+ automations_preferences_p.add_argument("--payload", default="", help="JSON object to persist")
4169
+ automations_preferences_p.add_argument("--payload-file", default="", help="Path to a JSON object to persist")
4170
+ automations_preferences_p.add_argument("--payload-stdin", action="store_true", help="Read JSON object from stdin")
4171
+ automations_preferences_p.add_argument("--json", action="store_true", help="JSON output")
4172
+
4173
+ morning_briefing_parser = sub.add_parser("morning-briefing", help="Read the latest operator morning briefing")
4174
+ morning_briefing_sub = morning_briefing_parser.add_subparsers(dest="morning_briefing_command")
4175
+ morning_latest_p = morning_briefing_sub.add_parser("latest", help="Show the latest sent morning briefing")
4176
+ morning_latest_p.add_argument("--include-non-sent", action="store_true", help="Include failed/in-progress rows for diagnostics")
4177
+ morning_latest_p.add_argument("--json", action="store_true", help="JSON output")
4178
+ for mark_command in ("mark-shown", "mark-opened", "mark-dismissed"):
4179
+ mark_p = morning_briefing_sub.add_parser(mark_command, help=f"{mark_command.replace('-', ' ')} for Desktop")
4180
+ mark_p.add_argument("--id", dest="briefing_id", type=int, default=None, help="Specific briefing id")
4181
+ mark_p.add_argument("--json", action="store_true", help="JSON output")
4182
+
4072
4183
  core_schedules_parser = sub.add_parser("core-schedules", help="Manage structural core cron cadences")
4073
4184
  core_schedules_sub = core_schedules_parser.add_subparsers(dest="core_schedules_command")
4074
4185
 
@@ -4658,8 +4769,17 @@ def main():
4658
4769
  return _automations_set_instructions(args)
4659
4770
  elif args.automations_command == "schedule":
4660
4771
  return _automations_set_schedule(args)
4772
+ elif args.automations_command == "preference-schema":
4773
+ return _automations_preference_schema(args)
4774
+ elif args.automations_command == "preferences":
4775
+ return _automations_preferences(args)
4661
4776
  automations_parser.print_help()
4662
4777
  return 0
4778
+ elif args.command == "morning-briefing":
4779
+ if args.morning_briefing_command in {"latest", "mark-shown", "mark-opened", "mark-dismissed"}:
4780
+ return _morning_briefing(args)
4781
+ morning_briefing_parser.print_help()
4782
+ return 0
4663
4783
  elif args.command == "core-schedules":
4664
4784
  if args.core_schedules_command == "list":
4665
4785
  return _core_schedules_list(args)
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
  ]