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/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/automation_controls.py +7 -0
- package/src/automation_preferences.py +323 -0
- package/src/cli.py +120 -0
- package/src/cli_email.py +95 -0
- package/src/db/_schema.py +18 -0
- package/src/email_presentation.py +243 -0
- package/src/morning_briefing.py +281 -0
- package/src/script_registry.py +15 -0
- package/src/scripts/nexo-morning-agent.py +118 -69
- package/src/scripts/nexo-send-reply.py +20 -25
- package/templates/core-prompts/morning-agent-json-output.md +1 -1
- package/templates/core-prompts/morning-agent.md +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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.
|
|
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
|
]
|