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
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
|
+
]
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
*,
|