nexo-brain 7.29.0 → 7.30.1
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 +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +72 -0
- package/src/automation_controls.py +180 -10
- package/src/automation_preferences.py +69 -21
- package/src/cli.py +37 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- package/src/plugins/desktop_preferences.py +63 -0
- package/src/plugins/personal_scripts.py +2 -0
- package/src/plugins/update.py +4 -0
- package/src/preference_catalog.py +438 -0
- package/src/resonance_tiers.json +4 -4
- package/src/script_registry.py +6 -0
- package/src/scripts/nexo-morning-agent.py +263 -3
- package/src/scripts/nexo-send-reply.py +29 -1
- package/src/server.py +1 -0
- package/templates/core-prompts/morning-agent.md +7 -0
- package/tool-enforcement-map.json +40 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.30.1",
|
|
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,11 @@
|
|
|
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.30.1` is the current packaged-runtime line. Patch release over v7.30.0 - morning briefings now behave more like a start-of-day assistant, explain news/weather as verified optional sources, and keep user type inference inside NEXO instead of asking the user to choose a role manually.
|
|
22
|
+
|
|
23
|
+
Previously in `7.30.0`: minor release over v7.29.0 - morning briefing preferences now support weather/news, selectable weekdays, safer SMTP certificates, Opus 4.8 defaults, and an agent-facing preference catalog so NEXO can explain or change supported settings by chat.
|
|
24
|
+
|
|
25
|
+
Previously in `7.29.0`: 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
26
|
|
|
23
27
|
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.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.30.1",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -2001,6 +2001,75 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
|
|
|
2001
2001
|
return actions
|
|
2002
2002
|
|
|
2003
2003
|
|
|
2004
|
+
def _refresh_resonance_tiers_model_defaults(dest: Path = NEXO_HOME) -> list[str]:
|
|
2005
|
+
"""Upgrade persisted resonance contracts that still use old NEXO defaults.
|
|
2006
|
+
|
|
2007
|
+
Existing users keep a public ``resonance_tiers.json`` contract in their
|
|
2008
|
+
personal brain. Source defaults alone do not update that file, so known
|
|
2009
|
+
Opus defaults must be migrated in place while custom models are left alone.
|
|
2010
|
+
"""
|
|
2011
|
+
actions: list[str] = []
|
|
2012
|
+
source_path = Path(__file__).resolve().parent / "resonance_tiers.json"
|
|
2013
|
+
try:
|
|
2014
|
+
source_payload = json.loads(source_path.read_text())
|
|
2015
|
+
except Exception as exc:
|
|
2016
|
+
return [f"resonance-default-refresh-warning:source:{exc.__class__.__name__}"]
|
|
2017
|
+
|
|
2018
|
+
target_paths = [
|
|
2019
|
+
dest / "personal" / "brain" / "resonance_tiers.json",
|
|
2020
|
+
dest / "brain" / "resonance_tiers.json",
|
|
2021
|
+
]
|
|
2022
|
+
old_prefixes = ("claude-opus-4-6", "claude-opus-4-7")
|
|
2023
|
+
|
|
2024
|
+
for target_path in target_paths:
|
|
2025
|
+
try:
|
|
2026
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2027
|
+
if target_path.is_file():
|
|
2028
|
+
payload = json.loads(target_path.read_text())
|
|
2029
|
+
else:
|
|
2030
|
+
if target_path != target_paths[0]:
|
|
2031
|
+
continue
|
|
2032
|
+
payload = json.loads(json.dumps(source_payload))
|
|
2033
|
+
except Exception as exc:
|
|
2034
|
+
actions.append(f"resonance-default-refresh-warning:read:{target_path.name}:{exc.__class__.__name__}")
|
|
2035
|
+
continue
|
|
2036
|
+
|
|
2037
|
+
changed = False
|
|
2038
|
+
tiers = payload.get("tiers") if isinstance(payload, dict) else {}
|
|
2039
|
+
source_tiers = source_payload.get("tiers") if isinstance(source_payload, dict) else {}
|
|
2040
|
+
if not isinstance(tiers, dict) or not isinstance(source_tiers, dict):
|
|
2041
|
+
continue
|
|
2042
|
+
|
|
2043
|
+
for tier_name, tier_payload in tiers.items():
|
|
2044
|
+
if not isinstance(tier_payload, dict):
|
|
2045
|
+
continue
|
|
2046
|
+
claude = tier_payload.get("claude_code")
|
|
2047
|
+
source_claude = (source_tiers.get(tier_name) or {}).get("claude_code") if isinstance(source_tiers.get(tier_name), dict) else {}
|
|
2048
|
+
if not isinstance(claude, dict) or not isinstance(source_claude, dict):
|
|
2049
|
+
continue
|
|
2050
|
+
model = str(claude.get("model") or "").strip()
|
|
2051
|
+
tier_changed = False
|
|
2052
|
+
if model and model.startswith(old_prefixes):
|
|
2053
|
+
claude["model"] = str(source_claude.get("model") or "claude-opus-4-8")
|
|
2054
|
+
tier_changed = True
|
|
2055
|
+
if tier_changed and not str(claude.get("effort") or "").strip() and source_claude.get("effort"):
|
|
2056
|
+
claude["effort"] = str(source_claude.get("effort"))
|
|
2057
|
+
changed = changed or tier_changed
|
|
2058
|
+
|
|
2059
|
+
if "default_tier" not in payload and source_payload.get("default_tier"):
|
|
2060
|
+
payload["default_tier"] = source_payload.get("default_tier")
|
|
2061
|
+
changed = True
|
|
2062
|
+
|
|
2063
|
+
if changed or not target_path.is_file():
|
|
2064
|
+
try:
|
|
2065
|
+
target_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
2066
|
+
actions.append(f"resonance-default-refresh:{target_path.name}")
|
|
2067
|
+
except Exception as exc:
|
|
2068
|
+
actions.append(f"resonance-default-refresh-warning:write:{target_path.name}:{exc.__class__.__name__}")
|
|
2069
|
+
|
|
2070
|
+
return actions
|
|
2071
|
+
|
|
2072
|
+
|
|
2004
2073
|
def _bootstrap_profile_from_calibration_meta(dest: Path = NEXO_HOME) -> list[str]:
|
|
2005
2074
|
"""Create ``brain/profile.json`` from ``calibration.json`` fields when the
|
|
2006
2075
|
profile file does not exist yet.
|
|
@@ -5045,6 +5114,9 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
5045
5114
|
reloc_actions = _relocate_resonance_tiers_contract(dest)
|
|
5046
5115
|
for action in reloc_actions:
|
|
5047
5116
|
actions.append(action)
|
|
5117
|
+
refresh_actions = _refresh_resonance_tiers_model_defaults(dest)
|
|
5118
|
+
for action in refresh_actions:
|
|
5119
|
+
actions.append(action)
|
|
5048
5120
|
except Exception as exc:
|
|
5049
5121
|
actions.append(f"resonance-contract-relocate-warning:{exc.__class__.__name__}")
|
|
5050
5122
|
|
|
@@ -44,6 +44,58 @@ _CORE_AUTOMATION_SCHEDULES: dict[str, dict[str, Any]] = {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
EXTRA_INSTRUCTIONS_METADATA_KEY = "operator_extra_instructions"
|
|
47
|
+
_WEEKDAY_ORDER = (1, 2, 3, 4, 5, 6, 0)
|
|
48
|
+
_WEEKDAY_LABELS = {
|
|
49
|
+
0: "Sun",
|
|
50
|
+
1: "Mon",
|
|
51
|
+
2: "Tue",
|
|
52
|
+
3: "Wed",
|
|
53
|
+
4: "Thu",
|
|
54
|
+
5: "Fri",
|
|
55
|
+
6: "Sat",
|
|
56
|
+
}
|
|
57
|
+
_WEEKDAY_ALIASES = {
|
|
58
|
+
"0": 0,
|
|
59
|
+
"7": 0,
|
|
60
|
+
"sun": 0,
|
|
61
|
+
"sunday": 0,
|
|
62
|
+
"domingo": 0,
|
|
63
|
+
"dom": 0,
|
|
64
|
+
"1": 1,
|
|
65
|
+
"mon": 1,
|
|
66
|
+
"monday": 1,
|
|
67
|
+
"lunes": 1,
|
|
68
|
+
"lun": 1,
|
|
69
|
+
"2": 2,
|
|
70
|
+
"tue": 2,
|
|
71
|
+
"tuesday": 2,
|
|
72
|
+
"martes": 2,
|
|
73
|
+
"mar": 2,
|
|
74
|
+
"3": 3,
|
|
75
|
+
"wed": 3,
|
|
76
|
+
"wednesday": 3,
|
|
77
|
+
"miercoles": 3,
|
|
78
|
+
"miércoles": 3,
|
|
79
|
+
"mie": 3,
|
|
80
|
+
"mié": 3,
|
|
81
|
+
"4": 4,
|
|
82
|
+
"thu": 4,
|
|
83
|
+
"thursday": 4,
|
|
84
|
+
"jueves": 4,
|
|
85
|
+
"jue": 4,
|
|
86
|
+
"5": 5,
|
|
87
|
+
"fri": 5,
|
|
88
|
+
"friday": 5,
|
|
89
|
+
"viernes": 5,
|
|
90
|
+
"vie": 5,
|
|
91
|
+
"6": 6,
|
|
92
|
+
"sat": 6,
|
|
93
|
+
"saturday": 6,
|
|
94
|
+
"sabado": 6,
|
|
95
|
+
"sábado": 6,
|
|
96
|
+
"sab": 6,
|
|
97
|
+
"sáb": 6,
|
|
98
|
+
}
|
|
47
99
|
|
|
48
100
|
|
|
49
101
|
_EMAIL_REQUIRED_SCRIPTS: dict[str, dict[str, Any]] = {
|
|
@@ -192,6 +244,98 @@ def get_core_manifest_cron(name: str) -> dict[str, Any]:
|
|
|
192
244
|
return {}
|
|
193
245
|
|
|
194
246
|
|
|
247
|
+
def _normalize_weekdays(value: Any) -> list[int] | None:
|
|
248
|
+
if value is None:
|
|
249
|
+
return None
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
raw = value.strip().lower()
|
|
252
|
+
if not raw or raw in {"all", "daily", "everyday", "cada dia", "cada día"}:
|
|
253
|
+
return []
|
|
254
|
+
parts = [part.strip() for part in raw.replace(";", ",").replace("|", ",").replace("+", ",").split(",")]
|
|
255
|
+
elif isinstance(value, (list, tuple, set)):
|
|
256
|
+
parts = list(value)
|
|
257
|
+
else:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
selected: set[int] = set()
|
|
261
|
+
invalid = False
|
|
262
|
+
for part in parts:
|
|
263
|
+
key = str(part).strip().lower()
|
|
264
|
+
if not key:
|
|
265
|
+
continue
|
|
266
|
+
if "-" in key:
|
|
267
|
+
left, right = [piece.strip() for piece in key.split("-", 1)]
|
|
268
|
+
start = _WEEKDAY_ALIASES.get(left)
|
|
269
|
+
end = _WEEKDAY_ALIASES.get(right)
|
|
270
|
+
if start is not None and end is not None:
|
|
271
|
+
ordered = list(_WEEKDAY_ORDER)
|
|
272
|
+
start_index = ordered.index(start)
|
|
273
|
+
end_index = ordered.index(end)
|
|
274
|
+
if start_index <= end_index:
|
|
275
|
+
selected.update(ordered[start_index:end_index + 1])
|
|
276
|
+
else:
|
|
277
|
+
selected.update(ordered[start_index:] + ordered[:end_index + 1])
|
|
278
|
+
continue
|
|
279
|
+
invalid = True
|
|
280
|
+
continue
|
|
281
|
+
if key in _WEEKDAY_ALIASES:
|
|
282
|
+
selected.add(_WEEKDAY_ALIASES[key])
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
day_number = int(key)
|
|
286
|
+
if 0 <= day_number <= 7:
|
|
287
|
+
selected.add(day_number % 7)
|
|
288
|
+
continue
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
invalid = True
|
|
292
|
+
if invalid:
|
|
293
|
+
return None
|
|
294
|
+
if len(selected) >= 7:
|
|
295
|
+
return []
|
|
296
|
+
return [day for day in _WEEKDAY_ORDER if day in selected]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _schedule_weekdays(schedule: dict[str, Any]) -> list[int]:
|
|
300
|
+
if not isinstance(schedule, dict):
|
|
301
|
+
return []
|
|
302
|
+
normalized = _normalize_weekdays(schedule.get("weekdays"))
|
|
303
|
+
if normalized is not None:
|
|
304
|
+
return normalized
|
|
305
|
+
if "weekday" in schedule:
|
|
306
|
+
normalized = _normalize_weekdays([schedule.get("weekday")])
|
|
307
|
+
return normalized or []
|
|
308
|
+
return []
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _apply_weekdays_to_schedule(schedule: dict[str, Any], weekdays: list[int] | None) -> dict[str, Any]:
|
|
312
|
+
next_schedule = dict(schedule)
|
|
313
|
+
next_schedule.pop("weekday", None)
|
|
314
|
+
if weekdays:
|
|
315
|
+
next_schedule["weekdays"] = weekdays
|
|
316
|
+
else:
|
|
317
|
+
next_schedule.pop("weekdays", None)
|
|
318
|
+
return next_schedule
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _same_calendar_schedule(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
322
|
+
def normalized(payload: dict[str, Any]) -> dict[str, Any]:
|
|
323
|
+
out = dict(payload or {})
|
|
324
|
+
weekdays = _schedule_weekdays(out)
|
|
325
|
+
out.pop("weekday", None)
|
|
326
|
+
out.pop("weekdays", None)
|
|
327
|
+
if weekdays:
|
|
328
|
+
out["weekdays"] = weekdays
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
return normalized(left) == normalized(right)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _normalize_calendar_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
|
335
|
+
normalized = dict(schedule or {})
|
|
336
|
+
return _apply_weekdays_to_schedule(normalized, _schedule_weekdays(normalized))
|
|
337
|
+
|
|
338
|
+
|
|
195
339
|
def _format_interval_label(interval_seconds: int) -> str:
|
|
196
340
|
interval = max(1, int(interval_seconds or 0))
|
|
197
341
|
if interval % 3600 == 0:
|
|
@@ -210,12 +354,9 @@ def _format_calendar_label(schedule: dict[str, Any]) -> str:
|
|
|
210
354
|
except Exception:
|
|
211
355
|
return ""
|
|
212
356
|
label = f"{hour:02d}:{minute:02d}"
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
except Exception:
|
|
217
|
-
weekday = 0
|
|
218
|
-
label += f" weekday={weekday}"
|
|
357
|
+
weekdays = _schedule_weekdays(schedule)
|
|
358
|
+
if weekdays:
|
|
359
|
+
label += " " + ",".join(_WEEKDAY_LABELS.get(day, str(day)) for day in weekdays)
|
|
219
360
|
else:
|
|
220
361
|
label += " daily"
|
|
221
362
|
return label
|
|
@@ -232,13 +373,15 @@ def _decorate_schedule_state(*, base_cron: dict[str, Any], effective_cron: dict[
|
|
|
232
373
|
}
|
|
233
374
|
schedule = effective_cron.get("schedule")
|
|
234
375
|
if isinstance(schedule, dict) and schedule:
|
|
376
|
+
normalized_schedule = _normalize_calendar_schedule(schedule)
|
|
377
|
+
normalized_default = _normalize_calendar_schedule(dict(base_cron.get("schedule") or {}))
|
|
235
378
|
return {
|
|
236
379
|
"schedule_type": "calendar",
|
|
237
380
|
"interval_seconds": 0,
|
|
238
381
|
"default_interval_seconds": 0,
|
|
239
|
-
"schedule":
|
|
240
|
-
"default_schedule":
|
|
241
|
-
"effective_schedule_label": _format_calendar_label(
|
|
382
|
+
"schedule": normalized_schedule,
|
|
383
|
+
"default_schedule": normalized_default,
|
|
384
|
+
"effective_schedule_label": _format_calendar_label(normalized_schedule),
|
|
242
385
|
"schedule_source": source,
|
|
243
386
|
}
|
|
244
387
|
return {
|
|
@@ -354,6 +497,7 @@ def set_core_automation_schedule(
|
|
|
354
497
|
*,
|
|
355
498
|
interval_seconds: int | None = None,
|
|
356
499
|
daily_at: str | None = None,
|
|
500
|
+
weekdays: Any = None,
|
|
357
501
|
clear: bool = False,
|
|
358
502
|
) -> dict[str, Any]:
|
|
359
503
|
clean_name = _normalize_name(name)
|
|
@@ -382,10 +526,15 @@ def set_core_automation_schedule(
|
|
|
382
526
|
parsed_daily_at = _parse_daily_at(daily_at or "")
|
|
383
527
|
if not parsed_daily_at:
|
|
384
528
|
return {"ok": False, "error": "daily_at must use HH:MM (24h) format"}
|
|
529
|
+
normalized_weekdays = _normalize_weekdays(weekdays)
|
|
530
|
+
if weekdays is not None and normalized_weekdays is None:
|
|
531
|
+
return {"ok": False, "error": "weekdays must be a comma-separated list like Mon-Fri or Tue,Sat"}
|
|
385
532
|
default_schedule = dict(base_cron.get("schedule") or {})
|
|
386
533
|
next_schedule = dict(default_schedule)
|
|
387
534
|
next_schedule.update(parsed_daily_at)
|
|
388
|
-
|
|
535
|
+
if normalized_weekdays is not None:
|
|
536
|
+
next_schedule = _apply_weekdays_to_schedule(next_schedule, normalized_weekdays)
|
|
537
|
+
next_override = {} if _same_calendar_schedule(next_schedule, default_schedule) else {"schedule": next_schedule}
|
|
389
538
|
else:
|
|
390
539
|
try:
|
|
391
540
|
normalized_interval = int(interval_seconds or 0)
|
|
@@ -420,6 +569,8 @@ def set_core_automation_schedule(
|
|
|
420
569
|
"schedule_type": str(state.get("schedule_type") or ""),
|
|
421
570
|
"schedule_source": str(state.get("schedule_source") or ""),
|
|
422
571
|
"effective_schedule_label": str(state.get("effective_schedule_label") or ""),
|
|
572
|
+
"schedule": state.get("schedule"),
|
|
573
|
+
"default_schedule": state.get("default_schedule"),
|
|
423
574
|
"interval_seconds": int(state.get("interval_seconds", 0) or 0),
|
|
424
575
|
"default_interval_seconds": int(state.get("default_interval_seconds", 0) or 0),
|
|
425
576
|
"minimum_interval_seconds": int(state.get("minimum_interval_seconds", 0) or 0),
|
|
@@ -687,6 +838,8 @@ def get_script_runtime_contract(name: str) -> dict[str, Any]:
|
|
|
687
838
|
"schedule_type": str(schedule_state.get("schedule_type") or ""),
|
|
688
839
|
"schedule_source": str(schedule_state.get("schedule_source") or ""),
|
|
689
840
|
"effective_schedule_label": str(schedule_state.get("effective_schedule_label") or ""),
|
|
841
|
+
"schedule": schedule_state.get("schedule"),
|
|
842
|
+
"default_schedule": schedule_state.get("default_schedule"),
|
|
690
843
|
"interval_seconds": int(schedule_state.get("interval_seconds", 0) or 0),
|
|
691
844
|
"default_interval_seconds": int(schedule_state.get("default_interval_seconds", 0) or 0),
|
|
692
845
|
"minimum_interval_seconds": int(schedule_state.get("minimum_interval_seconds", 0) or 0),
|
|
@@ -737,6 +890,7 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
737
890
|
language = "en"
|
|
738
891
|
operator_email = ""
|
|
739
892
|
operator_accounts: list[dict] = []
|
|
893
|
+
profile_payload: dict[str, Any] = {}
|
|
740
894
|
|
|
741
895
|
try:
|
|
742
896
|
from calibration_runtime import load_runtime_calibration
|
|
@@ -763,6 +917,13 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
763
917
|
or str(payload.get("lang") or "").strip()
|
|
764
918
|
or language
|
|
765
919
|
)
|
|
920
|
+
profile_path = brain_dir() / "profile.json"
|
|
921
|
+
if profile_path.exists():
|
|
922
|
+
loaded_profile = json.loads(profile_path.read_text())
|
|
923
|
+
if isinstance(loaded_profile, dict):
|
|
924
|
+
profile_payload = loaded_profile
|
|
925
|
+
operator_name = str(loaded_profile.get("name") or operator_name).strip() or operator_name
|
|
926
|
+
language = str(loaded_profile.get("language") or language).strip() or language
|
|
766
927
|
except Exception:
|
|
767
928
|
pass
|
|
768
929
|
|
|
@@ -797,6 +958,15 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
797
958
|
"operator_email": operator_email,
|
|
798
959
|
"operator_aliases": aliases,
|
|
799
960
|
"operator_accounts": operator_accounts,
|
|
961
|
+
"current_residence": profile_payload.get("current_residence"),
|
|
962
|
+
"timezone": profile_payload.get("timezone"),
|
|
963
|
+
"location": profile_payload.get("location"),
|
|
964
|
+
"coordinates": profile_payload.get("coordinates"),
|
|
965
|
+
"latitude": profile_payload.get("latitude"),
|
|
966
|
+
"longitude": profile_payload.get("longitude"),
|
|
967
|
+
"role": profile_payload.get("role"),
|
|
968
|
+
"technical_level": profile_payload.get("technical_level"),
|
|
969
|
+
"news_rss_url": profile_payload.get("news_rss_url"),
|
|
800
970
|
}
|
|
801
971
|
|
|
802
972
|
|
|
@@ -22,29 +22,75 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
22
22
|
"id": "content",
|
|
23
23
|
"label": "Content",
|
|
24
24
|
"items": [
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{
|
|
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
|
+
},
|
|
33
81
|
{
|
|
34
82
|
"id": "news",
|
|
35
83
|
"type": "boolean",
|
|
36
84
|
"label": "News",
|
|
37
85
|
"default": False,
|
|
38
|
-
"
|
|
39
|
-
"disabled_reason": "No verified news source is connected yet.",
|
|
86
|
+
"help": "A short set of current public headlines from the configured news feed, included only when the source can be verified.",
|
|
40
87
|
},
|
|
41
88
|
{
|
|
42
89
|
"id": "weather",
|
|
43
90
|
"type": "boolean",
|
|
44
91
|
"label": "Weather",
|
|
45
|
-
"default":
|
|
46
|
-
"
|
|
47
|
-
"disabled_reason": "No verified weather source is connected yet.",
|
|
92
|
+
"default": True,
|
|
93
|
+
"help": "Today's weather from the location saved in Desktop or your residence in the profile, included only when the forecast can be verified.",
|
|
48
94
|
},
|
|
49
95
|
],
|
|
50
96
|
},
|
|
@@ -58,6 +104,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
58
104
|
"label": "Length",
|
|
59
105
|
"default": "normal",
|
|
60
106
|
"options": ["short", "normal", "detailed"],
|
|
107
|
+
"help": "How much detail the briefing should include.",
|
|
61
108
|
},
|
|
62
109
|
{
|
|
63
110
|
"id": "tone",
|
|
@@ -65,6 +112,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
65
112
|
"label": "Tone",
|
|
66
113
|
"default": "direct",
|
|
67
114
|
"options": ["direct", "warm", "executive", "personal"],
|
|
115
|
+
"help": "How NEXO should write the summary.",
|
|
68
116
|
},
|
|
69
117
|
{
|
|
70
118
|
"id": "format",
|
|
@@ -72,6 +120,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
72
120
|
"label": "Format",
|
|
73
121
|
"default": "sections",
|
|
74
122
|
"options": ["sections", "bullets", "narrative"],
|
|
123
|
+
"help": "How the briefing is organized visually.",
|
|
75
124
|
},
|
|
76
125
|
],
|
|
77
126
|
},
|
|
@@ -85,13 +134,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
85
134
|
"label": "Quiet days",
|
|
86
135
|
"default": "summary_if_anything_important",
|
|
87
136
|
"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"],
|
|
137
|
+
"help": "What NEXO should do on days with little or no important activity.",
|
|
95
138
|
},
|
|
96
139
|
],
|
|
97
140
|
},
|
|
@@ -279,6 +322,7 @@ def search_automation_preference_schema(name: str, query: str) -> list[dict[str,
|
|
|
279
322
|
str(item.get("id") or ""),
|
|
280
323
|
str(item.get("label") or ""),
|
|
281
324
|
str(item.get("disabled_reason") or ""),
|
|
325
|
+
str(item.get("help") or ""),
|
|
282
326
|
str(group.get("label") or ""),
|
|
283
327
|
])
|
|
284
328
|
if clean_query in _fold_text(text):
|
|
@@ -304,8 +348,12 @@ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
|
|
|
304
348
|
return (
|
|
305
349
|
"\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
|
|
306
350
|
f"{compact}\n"
|
|
351
|
+
"Morning briefing intent: act like a professional personal assistant preparing the operator for the day. "
|
|
352
|
+
"Do not merely list available records; filter, rank, and explain what deserves attention first.\n"
|
|
353
|
+
"Adapt the emphasis from the operator profile, role, recent activity, and context. "
|
|
354
|
+
"Do not ask the user to choose a user type manually and do not assume a profession unless the context supports it.\n"
|
|
307
355
|
"Use these preferences to decide what to include, omit, and emphasize. "
|
|
308
|
-
"Disabled/unavailable data sources must not be invented.\n"
|
|
356
|
+
"Disabled/unavailable data sources must not be invented; news and weather require verified collected data.\n"
|
|
309
357
|
)
|
|
310
358
|
|
|
311
359
|
|
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:
|
|
@@ -2612,6 +2620,28 @@ def _preferences(args):
|
|
|
2612
2620
|
_load_user_default_resonance,
|
|
2613
2621
|
)
|
|
2614
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
|
+
|
|
2615
2645
|
prefs = load_client_preferences()
|
|
2616
2646
|
if not isinstance(prefs, dict):
|
|
2617
2647
|
prefs = {}
|
|
@@ -4150,6 +4180,7 @@ def main():
|
|
|
4150
4180
|
automations_schedule_group.add_argument("--every-seconds", type=int, help="Run the automation every N seconds")
|
|
4151
4181
|
automations_schedule_group.add_argument("--daily-at", type=str, help="Run the automation every day at HH:MM (24h)")
|
|
4152
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")
|
|
4153
4184
|
automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
|
|
4154
4185
|
|
|
4155
4186
|
automations_schema_p = automations_sub.add_parser(
|
|
@@ -4281,6 +4312,12 @@ def main():
|
|
|
4281
4312
|
"preferences",
|
|
4282
4313
|
help="Read or change NEXO user preferences (resonance, default client, ...)",
|
|
4283
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")
|
|
4284
4321
|
preferences_parser.add_argument(
|
|
4285
4322
|
"--resonance",
|
|
4286
4323
|
choices=["maximo", "alto", "medio", "bajo"],
|