nexo-brain 7.28.0 → 7.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +187 -10
- package/src/automation_preferences.py +367 -0
- package/src/cli.py +157 -0
- package/src/cli_email.py +95 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/db/_schema.py +18 -0
- package/src/email_presentation.py +243 -0
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- package/src/morning_briefing.py +281 -0
- 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 +21 -0
- package/src/scripts/nexo-morning-agent.py +380 -71
- package/src/scripts/nexo-send-reply.py +49 -26
- package/src/server.py +1 -0
- package/templates/core-prompts/morning-agent-json-output.md +1 -1
- package/templates/core-prompts/morning-agent.md +5 -2
- 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.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,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.0` is the current packaged-runtime line. 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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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
26
|
|
|
23
27
|
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
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.30.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",
|
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),
|
|
@@ -672,14 +823,23 @@ def get_script_runtime_contract(name: str) -> dict[str, Any]:
|
|
|
672
823
|
blocked_reason = str(recipient_status.get("reason") or "")
|
|
673
824
|
blocked_reason_code = str(recipient_status.get("reason_code") or "")
|
|
674
825
|
|
|
826
|
+
try:
|
|
827
|
+
from automation_preferences import supports_automation_preferences
|
|
828
|
+
preferences_supported = supports_automation_preferences(clean_name)
|
|
829
|
+
except Exception:
|
|
830
|
+
preferences_supported = False
|
|
831
|
+
|
|
675
832
|
return {
|
|
676
833
|
"name": clean_name,
|
|
677
834
|
"toggleable_core": is_toggleable_core_script(clean_name),
|
|
678
835
|
"supports_extra_instructions": supports_operator_extra_instructions(clean_name),
|
|
836
|
+
"supports_automation_preferences": preferences_supported,
|
|
679
837
|
"schedule_configurable": bool(schedule_state.get("schedule_configurable")),
|
|
680
838
|
"schedule_type": str(schedule_state.get("schedule_type") or ""),
|
|
681
839
|
"schedule_source": str(schedule_state.get("schedule_source") or ""),
|
|
682
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"),
|
|
683
843
|
"interval_seconds": int(schedule_state.get("interval_seconds", 0) or 0),
|
|
684
844
|
"default_interval_seconds": int(schedule_state.get("default_interval_seconds", 0) or 0),
|
|
685
845
|
"minimum_interval_seconds": int(schedule_state.get("minimum_interval_seconds", 0) or 0),
|
|
@@ -730,6 +890,7 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
730
890
|
language = "en"
|
|
731
891
|
operator_email = ""
|
|
732
892
|
operator_accounts: list[dict] = []
|
|
893
|
+
profile_payload: dict[str, Any] = {}
|
|
733
894
|
|
|
734
895
|
try:
|
|
735
896
|
from calibration_runtime import load_runtime_calibration
|
|
@@ -756,6 +917,13 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
756
917
|
or str(payload.get("lang") or "").strip()
|
|
757
918
|
or language
|
|
758
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
|
|
759
927
|
except Exception:
|
|
760
928
|
pass
|
|
761
929
|
|
|
@@ -790,6 +958,15 @@ def get_operator_profile() -> dict[str, Any]:
|
|
|
790
958
|
"operator_email": operator_email,
|
|
791
959
|
"operator_aliases": aliases,
|
|
792
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"),
|
|
793
970
|
}
|
|
794
971
|
|
|
795
972
|
|