nexo-brain 2.6.20 → 2.7.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 +16 -17
- package/package.json +1 -1
- package/src/agent_runner.py +6 -2
- package/src/cli.py +98 -4
- package/src/client_preferences.py +21 -0
- package/src/dashboard/app.py +124 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/doctor/providers/runtime.py +181 -0
- package/src/scripts/deep-sleep/apply_findings.py +713 -10
- package/src/scripts/deep-sleep/synthesize-prompt.md +23 -0
- package/src/scripts/deep-sleep/synthesize.py +94 -0
- package/templates/nexo_helper.py +44 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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
|
@@ -38,17 +38,15 @@ That means NEXO now manages not only the shared runtime and MCP wiring, but also
|
|
|
38
38
|
- For Codex specifically, `nexo chat` and Codex headless automation inject the current bootstrap explicitly, so Codex starts as NEXO even when plain global Codex startup is inconsistent about global instructions.
|
|
39
39
|
- Deep Sleep now reads both Claude Code and Codex transcript stores, so overnight analysis still works even when the user spends the day in Codex.
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
- Existing installs that already had NEXO connected to Codex now backfill that client state automatically during update/sync, so the managed Codex bootstrap actually lands without manual cleanup.
|
|
51
|
-
- Bootstrap docs now fall back to the operator name `NEXO` when local metadata is blank, avoiding broken headings in `CLAUDE.md` and `AGENTS.md`.
|
|
41
|
+
Versions `2.6.14` through `2.6.21` established the practical shared-brain baseline: managed Claude/Codex bootstrap, Codex config sync, transcript-aware Deep Sleep, 60-day long-horizon analysis, weekly/monthly summary artifacts, retrieval auto-mode, and the first Deep Sleep engineering loop.
|
|
42
|
+
|
|
43
|
+
Version `2.7.0` closes the next operational gap:
|
|
44
|
+
|
|
45
|
+
- Weekly/monthly Deep Sleep summaries now include protocol compliance, engineering-loop output, project pulse, and trend-vs-previous-period data.
|
|
46
|
+
- Runtime doctor now audits both weekly protocol compliance and release-artifact sync drift instead of leaving those checks implicit.
|
|
47
|
+
- The repo now ships `scripts/verify_release_readiness.py`, and tagged publish runs it automatically so release discipline is enforced in the product itself.
|
|
48
|
+
- The dashboard now surfaces `What Matters Now`, `What Is Drifting`, and `What Is Improving` directly from the periodic Deep Sleep summaries.
|
|
49
|
+
- The unreleased Codex launcher fixes after `v2.6.21` are now included: stronger `nexo chat` client selection, corrected launch mode handling, tracked last terminal choice, and aligned interactive flags.
|
|
52
50
|
|
|
53
51
|
### Client Capability Matrix
|
|
54
52
|
|
|
@@ -522,7 +520,7 @@ npx nexo-brain # detects current version, migrates automatically
|
|
|
522
520
|
|
|
523
521
|
NEXO Brain includes a local CLI that runs independently of any single terminal client:
|
|
524
522
|
|
|
525
|
-
- `nexo chat` — launch
|
|
523
|
+
- `nexo chat` — launch a NEXO terminal client; if both Claude Code and Codex are available, it asks every time which one to open and puts the last-used client first
|
|
526
524
|
- `nexo update` — sync runtime from source, run migrations, reconcile schedules
|
|
527
525
|
- `nexo doctor --tier runtime` — boot/runtime/deep diagnostics with `--fix` mode
|
|
528
526
|
- `nexo scripts list` — list all personal scripts and their status
|
|
@@ -635,20 +633,20 @@ The installer handles everything and syncs the same `nexo` MCP brain into Claude
|
|
|
635
633
|
After install, use the runtime CLI:
|
|
636
634
|
|
|
637
635
|
```bash
|
|
638
|
-
nexo chat # Launch
|
|
636
|
+
nexo chat # Launch a NEXO terminal client (asks if both Claude Code and Codex are available)
|
|
639
637
|
nexo doctor # Check runtime health
|
|
640
638
|
nexo update # Pull latest version and sync
|
|
641
639
|
nexo clients sync # Re-sync Claude Code/Desktop/Codex to the same brain
|
|
642
640
|
nexo scripts list # See your personal scripts
|
|
643
641
|
```
|
|
644
642
|
|
|
645
|
-
During install, NEXO now asks which interactive clients you want to connect, which one `nexo chat` should
|
|
643
|
+
During install, NEXO now asks which interactive clients you want to connect, which one `nexo chat` should suggest first when multiple terminal clients are available, whether to enable background automation, which backend should run that automation, and which model profile each active terminal/backend should use. Shared brain stays on in every mode.
|
|
646
644
|
|
|
647
645
|
Recommended defaults:
|
|
648
646
|
- Claude Code: `Opus 4.6 with 1M context`
|
|
649
647
|
- Codex: `gpt-5.4` with `xhigh` reasoning
|
|
650
648
|
|
|
651
|
-
Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens
|
|
649
|
+
Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens the terminal client you pick for that session, with the last-used option shown first.
|
|
652
650
|
|
|
653
651
|
Your operator will greet you immediately — adapted to the time of day, resuming from where you left off. No cold starts.
|
|
654
652
|
|
|
@@ -664,6 +662,7 @@ The project still recommends Claude Code as the primary path, but contributions
|
|
|
664
662
|
|
|
665
663
|
Maintainers and contributors touching startup, bootstrap, Deep Sleep, or shared-brain behavior should also use the client parity checklist:
|
|
666
664
|
- [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
|
|
665
|
+
- `python3 scripts/verify_release_readiness.py`
|
|
667
666
|
|
|
668
667
|
### What Gets Installed
|
|
669
668
|
|
|
@@ -841,7 +840,7 @@ When Claude Desktop is installed, `nexo-brain`, `nexo update`, and `nexo clients
|
|
|
841
840
|
|
|
842
841
|
### Codex
|
|
843
842
|
|
|
844
|
-
When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. The current recommended Codex profile is `gpt-5.4` with `xhigh` reasoning.
|
|
843
|
+
When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. Interactive `nexo chat` launches use Codex's aggressive no-confirmation mode so the session does not stall on repetitive approval prompts. The current recommended Codex profile is `gpt-5.4` with `xhigh` reasoning.
|
|
845
844
|
|
|
846
845
|
### OpenClaw
|
|
847
846
|
|
|
@@ -955,7 +954,7 @@ If NEXO Brain is useful to you, consider:
|
|
|
955
954
|
- **Personal scripts registry**: Scripts in `NEXO_HOME/scripts/` tracked in SQLite with metadata, categories, schedules. Full lifecycle: create, sync, reconcile, schedule, unschedule, remove.
|
|
956
955
|
- **Orchestrator removed from core** (breaking): Was opt-in personal automation adding complexity for all users. Existing users keep their setup in `NEXO_HOME/scripts/`.
|
|
957
956
|
- **Claude Code plugin structure**: `plugin.json`, entry point, packaging for marketplace submission.
|
|
958
|
-
- **`nexo chat`**: Official command to launch
|
|
957
|
+
- **`nexo chat`**: Official command to launch a NEXO terminal client, asking when multiple supported terminal clients are available.
|
|
959
958
|
- **Managed Evolution hardening**: Can modify core behavior modules with rollback followups.
|
|
960
959
|
- Cron recovery hardened: TCC diagnostics, keepalive sync, personal schedule catchup.
|
|
961
960
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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/agent_runner.py
CHANGED
|
@@ -109,6 +109,10 @@ def _codex_initial_messages_config(prompt_text: str) -> str:
|
|
|
109
109
|
return f'initial_messages=[{{role="system",content={json.dumps(prompt_text, ensure_ascii=False)}}}]'
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def _codex_interactive_launch_flags() -> list[str]:
|
|
113
|
+
return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
|
|
114
|
+
|
|
115
|
+
|
|
112
116
|
def build_interactive_client_command(
|
|
113
117
|
*,
|
|
114
118
|
target: str | os.PathLike[str],
|
|
@@ -140,7 +144,7 @@ def build_interactive_client_command(
|
|
|
140
144
|
raise TerminalClientUnavailableError(
|
|
141
145
|
"Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
|
|
142
146
|
)
|
|
143
|
-
cmd = [codex_bin]
|
|
147
|
+
cmd = [codex_bin, *_codex_interactive_launch_flags()]
|
|
144
148
|
bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
|
|
145
149
|
if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
|
|
146
150
|
cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
|
|
@@ -201,7 +205,7 @@ def build_followup_terminal_shell_command(
|
|
|
201
205
|
"Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
|
|
202
206
|
)
|
|
203
207
|
target_cwd = str(Path(cwd).expanduser()) if cwd else str(Path.home())
|
|
204
|
-
cmd = [codex_bin]
|
|
208
|
+
cmd = [codex_bin, *_codex_interactive_launch_flags()]
|
|
205
209
|
bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
|
|
206
210
|
if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
|
|
207
211
|
cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
|
package/src/cli.py
CHANGED
|
@@ -41,6 +41,11 @@ from pathlib import Path
|
|
|
41
41
|
|
|
42
42
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
43
43
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
44
|
+
TERMINAL_CLIENT_LABELS = {
|
|
45
|
+
"claude_code": "Claude Code",
|
|
46
|
+
"codex": "Codex",
|
|
47
|
+
}
|
|
48
|
+
TERMINAL_CLIENT_ORDER = ("claude_code", "codex")
|
|
44
49
|
|
|
45
50
|
|
|
46
51
|
def _get_version() -> str:
|
|
@@ -844,8 +849,73 @@ def _dashboard(args):
|
|
|
844
849
|
return _service_control("dashboard", args.action)
|
|
845
850
|
|
|
846
851
|
|
|
852
|
+
def _terminal_client_label(client: str) -> str:
|
|
853
|
+
return TERMINAL_CLIENT_LABELS.get(client, client.replace("_", " ").title())
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
|
|
857
|
+
enabled = preferences.get("interactive_clients", {})
|
|
858
|
+
last_used = str(preferences.get("last_terminal_client", "")).strip()
|
|
859
|
+
preferred = str(preferences.get("default_terminal_client", "")).strip()
|
|
860
|
+
ordered: list[str] = []
|
|
861
|
+
|
|
862
|
+
for client in (last_used, preferred, *TERMINAL_CLIENT_ORDER):
|
|
863
|
+
if client in TERMINAL_CLIENT_ORDER and client not in ordered:
|
|
864
|
+
ordered.append(client)
|
|
865
|
+
|
|
866
|
+
return [
|
|
867
|
+
client
|
|
868
|
+
for client in ordered
|
|
869
|
+
if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
|
|
870
|
+
]
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
|
|
874
|
+
last_used = str(preferences.get("last_terminal_client", "")).strip()
|
|
875
|
+
if clients and clients[0] == last_used:
|
|
876
|
+
return "last choice"
|
|
877
|
+
return "default"
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _prompt_for_terminal_client(
|
|
881
|
+
clients: list[str],
|
|
882
|
+
normalize_client_key,
|
|
883
|
+
*,
|
|
884
|
+
preferred_label: str = "default",
|
|
885
|
+
) -> str | None:
|
|
886
|
+
if not clients:
|
|
887
|
+
return None
|
|
888
|
+
if len(clients) == 1:
|
|
889
|
+
return clients[0]
|
|
890
|
+
|
|
891
|
+
while True:
|
|
892
|
+
print("Select terminal client for this chat:")
|
|
893
|
+
for index, client in enumerate(clients, start=1):
|
|
894
|
+
suffix = f" [{preferred_label}]" if index == 1 else ""
|
|
895
|
+
print(f" {index}. {_terminal_client_label(client)}{suffix}")
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
response = input(f"Choose 1-{len(clients)} [1]: ").strip()
|
|
899
|
+
except EOFError:
|
|
900
|
+
return clients[0]
|
|
901
|
+
|
|
902
|
+
if not response:
|
|
903
|
+
return clients[0]
|
|
904
|
+
if response.isdigit():
|
|
905
|
+
choice = int(response)
|
|
906
|
+
if 1 <= choice <= len(clients):
|
|
907
|
+
return clients[choice - 1]
|
|
908
|
+
|
|
909
|
+
client_key = normalize_client_key(response)
|
|
910
|
+
if client_key in clients:
|
|
911
|
+
return client_key
|
|
912
|
+
|
|
913
|
+
print("Invalid choice. Try again.", file=sys.stderr)
|
|
914
|
+
|
|
915
|
+
|
|
847
916
|
def _chat(args):
|
|
848
917
|
target = args.path or "."
|
|
918
|
+
selected_client = getattr(args, "client", None)
|
|
849
919
|
|
|
850
920
|
try:
|
|
851
921
|
from auto_update import startup_preflight
|
|
@@ -867,20 +937,44 @@ def _chat(args):
|
|
|
867
937
|
pass
|
|
868
938
|
|
|
869
939
|
try:
|
|
940
|
+
from client_preferences import (
|
|
941
|
+
detect_installed_clients,
|
|
942
|
+
load_client_preferences,
|
|
943
|
+
normalize_client_key,
|
|
944
|
+
save_client_preferences,
|
|
945
|
+
)
|
|
870
946
|
from agent_runner import TerminalClientUnavailableError, launch_interactive_client
|
|
871
947
|
except ImportError:
|
|
872
948
|
print("Agent runner module not found. Ensure NEXO is properly installed.", file=sys.stderr)
|
|
873
949
|
return 1
|
|
874
950
|
|
|
951
|
+
if not selected_client:
|
|
952
|
+
try:
|
|
953
|
+
preferences = load_client_preferences()
|
|
954
|
+
detected = detect_installed_clients()
|
|
955
|
+
clients = _ordered_available_terminal_clients(preferences, detected)
|
|
956
|
+
selected_client = _prompt_for_terminal_client(
|
|
957
|
+
clients,
|
|
958
|
+
normalize_client_key,
|
|
959
|
+
preferred_label=_preferred_terminal_client_label(preferences, clients),
|
|
960
|
+
)
|
|
961
|
+
except Exception:
|
|
962
|
+
selected_client = None
|
|
963
|
+
|
|
875
964
|
try:
|
|
876
965
|
result = launch_interactive_client(
|
|
877
966
|
target=target,
|
|
878
|
-
client=
|
|
967
|
+
client=selected_client,
|
|
879
968
|
env=os.environ.copy(),
|
|
880
969
|
)
|
|
881
970
|
except TerminalClientUnavailableError as exc:
|
|
882
971
|
print(str(exc), file=sys.stderr)
|
|
883
972
|
return 1
|
|
973
|
+
if result.returncode == 0 and selected_client:
|
|
974
|
+
try:
|
|
975
|
+
save_client_preferences(last_terminal_client=normalize_client_key(selected_client))
|
|
976
|
+
except Exception:
|
|
977
|
+
pass
|
|
884
978
|
return int(result.returncode)
|
|
885
979
|
|
|
886
980
|
|
|
@@ -1014,7 +1108,7 @@ def _print_help():
|
|
|
1014
1108
|
print(f"""NEXO Runtime CLI v{v}
|
|
1015
1109
|
|
|
1016
1110
|
Commands:
|
|
1017
|
-
nexo chat [path] [--client claude_code|codex] Launch
|
|
1111
|
+
nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
|
|
1018
1112
|
nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
|
|
1019
1113
|
nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
|
|
1020
1114
|
Personal scripts
|
|
@@ -1036,12 +1130,12 @@ def main():
|
|
|
1036
1130
|
sub = parser.add_subparsers(dest="command")
|
|
1037
1131
|
|
|
1038
1132
|
# -- chat --
|
|
1039
|
-
chat_parser = sub.add_parser("chat", help="Launch
|
|
1133
|
+
chat_parser = sub.add_parser("chat", help="Launch a NEXO terminal client")
|
|
1040
1134
|
chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
|
|
1041
1135
|
chat_parser.add_argument(
|
|
1042
1136
|
"--client",
|
|
1043
1137
|
choices=["claude_code", "codex"],
|
|
1044
|
-
help="Override the
|
|
1138
|
+
help="Override the chat picker and launch a specific terminal client",
|
|
1045
1139
|
)
|
|
1046
1140
|
|
|
1047
1141
|
# -- scripts --
|
|
@@ -73,6 +73,7 @@ def default_client_preferences() -> dict:
|
|
|
73
73
|
CLIENT_CLAUDE_DESKTOP: False,
|
|
74
74
|
},
|
|
75
75
|
"default_terminal_client": CLIENT_CLAUDE_CODE,
|
|
76
|
+
"last_terminal_client": "",
|
|
76
77
|
"automation_enabled": True,
|
|
77
78
|
"automation_backend": CLIENT_CLAUDE_CODE,
|
|
78
79
|
"client_runtime_profiles": default_client_runtime_profiles(),
|
|
@@ -189,6 +190,14 @@ def normalize_default_terminal_client(value, interactive_clients: dict[str, bool
|
|
|
189
190
|
return CLIENT_CLAUDE_CODE
|
|
190
191
|
|
|
191
192
|
|
|
193
|
+
def normalize_last_terminal_client(value, interactive_clients: dict[str, bool] | None = None) -> str:
|
|
194
|
+
interactive_clients = normalize_interactive_clients(interactive_clients or {})
|
|
195
|
+
candidate = normalize_client_key(value)
|
|
196
|
+
if candidate in TERMINAL_CLIENT_KEYS and interactive_clients.get(candidate, False):
|
|
197
|
+
return candidate
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
192
201
|
def normalize_automation_enabled(value) -> bool:
|
|
193
202
|
return _coerce_bool(value, True)
|
|
194
203
|
|
|
@@ -282,6 +291,10 @@ def normalize_client_preferences(
|
|
|
282
291
|
schedule.get("default_terminal_client"),
|
|
283
292
|
interactive_clients=interactive_clients,
|
|
284
293
|
)
|
|
294
|
+
last_terminal_client = normalize_last_terminal_client(
|
|
295
|
+
schedule.get("last_terminal_client"),
|
|
296
|
+
interactive_clients=interactive_clients,
|
|
297
|
+
)
|
|
285
298
|
automation_backend = normalize_automation_backend(
|
|
286
299
|
schedule.get("automation_backend"),
|
|
287
300
|
automation_enabled=automation_enabled,
|
|
@@ -295,6 +308,7 @@ def normalize_client_preferences(
|
|
|
295
308
|
return {
|
|
296
309
|
"interactive_clients": interactive_clients,
|
|
297
310
|
"default_terminal_client": default_terminal_client,
|
|
311
|
+
"last_terminal_client": last_terminal_client,
|
|
298
312
|
"automation_enabled": automation_enabled,
|
|
299
313
|
"automation_backend": automation_backend,
|
|
300
314
|
"client_runtime_profiles": runtime_profiles,
|
|
@@ -307,6 +321,7 @@ def apply_client_preferences(
|
|
|
307
321
|
*,
|
|
308
322
|
interactive_clients: dict | None = None,
|
|
309
323
|
default_terminal_client: str | None = None,
|
|
324
|
+
last_terminal_client: str | None = None,
|
|
310
325
|
automation_enabled=None,
|
|
311
326
|
automation_backend: str | None = None,
|
|
312
327
|
client_runtime_profiles: dict | None = None,
|
|
@@ -324,6 +339,10 @@ def apply_client_preferences(
|
|
|
324
339
|
default_terminal_client if default_terminal_client is not None else current["default_terminal_client"],
|
|
325
340
|
interactive_clients=merged["interactive_clients"],
|
|
326
341
|
)
|
|
342
|
+
merged["last_terminal_client"] = normalize_last_terminal_client(
|
|
343
|
+
last_terminal_client if last_terminal_client is not None else current.get("last_terminal_client", ""),
|
|
344
|
+
interactive_clients=merged["interactive_clients"],
|
|
345
|
+
)
|
|
327
346
|
merged["automation_backend"] = normalize_automation_backend(
|
|
328
347
|
automation_backend if automation_backend is not None else current["automation_backend"],
|
|
329
348
|
automation_enabled=merged["automation_enabled"],
|
|
@@ -349,6 +368,7 @@ def save_client_preferences(
|
|
|
349
368
|
*,
|
|
350
369
|
interactive_clients: dict | None = None,
|
|
351
370
|
default_terminal_client: str | None = None,
|
|
371
|
+
last_terminal_client: str | None = None,
|
|
352
372
|
automation_enabled=None,
|
|
353
373
|
automation_backend: str | None = None,
|
|
354
374
|
client_runtime_profiles: dict | None = None,
|
|
@@ -358,6 +378,7 @@ def save_client_preferences(
|
|
|
358
378
|
load_schedule_config(),
|
|
359
379
|
interactive_clients=interactive_clients,
|
|
360
380
|
default_terminal_client=default_terminal_client,
|
|
381
|
+
last_terminal_client=last_terminal_client,
|
|
361
382
|
automation_enabled=automation_enabled,
|
|
362
383
|
automation_backend=automation_backend,
|
|
363
384
|
client_runtime_profiles=client_runtime_profiles,
|
package/src/dashboard/app.py
CHANGED
|
@@ -156,6 +156,106 @@ def _email_db():
|
|
|
156
156
|
return conn
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
def _deep_sleep_dir() -> Path:
|
|
160
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
161
|
+
return nexo_home / "operations" / "deep-sleep"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _latest_periodic_summary(kind: str) -> dict:
|
|
165
|
+
root = _deep_sleep_dir()
|
|
166
|
+
pattern = f"*-{kind}-summary.json"
|
|
167
|
+
candidates = []
|
|
168
|
+
for path in root.glob(pattern):
|
|
169
|
+
try:
|
|
170
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
171
|
+
except Exception:
|
|
172
|
+
continue
|
|
173
|
+
label = str(payload.get("label", "") or "")
|
|
174
|
+
if label:
|
|
175
|
+
candidates.append((label, payload))
|
|
176
|
+
if not candidates:
|
|
177
|
+
return {}
|
|
178
|
+
return sorted(candidates, key=lambda item: item[0])[-1][1]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _summarize_engineering_loop(weekly: dict, monthly: dict) -> dict:
|
|
182
|
+
matters_now = []
|
|
183
|
+
for item in (weekly.get("project_pulse") or weekly.get("top_projects") or [])[:4]:
|
|
184
|
+
matters_now.append(
|
|
185
|
+
{
|
|
186
|
+
"title": str(item.get("project", "") or "unknown"),
|
|
187
|
+
"detail": f"score {item.get('score', 0)}",
|
|
188
|
+
"tone": str(item.get("status", "watch") or "watch"),
|
|
189
|
+
"meta": ", ".join(item.get("reasons", [])[:2]) if isinstance(item.get("reasons"), list) else "",
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
drifting = []
|
|
194
|
+
protocol = weekly.get("protocol_summary") or {}
|
|
195
|
+
for key, label in (
|
|
196
|
+
("guard_check", "guard_check"),
|
|
197
|
+
("heartbeat", "heartbeat"),
|
|
198
|
+
("change_log", "change_log"),
|
|
199
|
+
):
|
|
200
|
+
item = protocol.get(key) or {}
|
|
201
|
+
pct = item.get("compliance_pct")
|
|
202
|
+
if isinstance(pct, (int, float)) and pct < 70:
|
|
203
|
+
drifting.append(
|
|
204
|
+
{
|
|
205
|
+
"title": label,
|
|
206
|
+
"detail": f"{pct:.1f}% compliance",
|
|
207
|
+
"tone": "critical" if pct < 45 else "elevated",
|
|
208
|
+
"meta": "",
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
for item in (weekly.get("top_patterns") or [])[:3]:
|
|
212
|
+
pattern = str(item.get("pattern", "") or "")
|
|
213
|
+
if pattern:
|
|
214
|
+
drifting.append(
|
|
215
|
+
{
|
|
216
|
+
"title": pattern,
|
|
217
|
+
"detail": f"{item.get('count', 0)}x this period",
|
|
218
|
+
"tone": "watch",
|
|
219
|
+
"meta": "recurring pattern",
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
if len(drifting) >= 4:
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
improving = []
|
|
226
|
+
trend = weekly.get("trend") or {}
|
|
227
|
+
trust_delta = trend.get("avg_trust_delta")
|
|
228
|
+
if isinstance(trust_delta, (int, float)) and trust_delta > 0:
|
|
229
|
+
improving.append({"title": "Trust", "detail": f"{trust_delta:+.1f}", "tone": "healthy", "meta": "vs previous window"})
|
|
230
|
+
delivery = weekly.get("delivery_metrics") or {}
|
|
231
|
+
if int(delivery.get("engineering_followups", 0) or 0) > 0:
|
|
232
|
+
improving.append(
|
|
233
|
+
{
|
|
234
|
+
"title": "Engineering followups",
|
|
235
|
+
"detail": str(delivery.get("engineering_followups", 0)),
|
|
236
|
+
"tone": "healthy",
|
|
237
|
+
"meta": "guardrails created from recurring patterns",
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
protocol_delta = trend.get("protocol_compliance_delta")
|
|
241
|
+
if isinstance(protocol_delta, (int, float)) and protocol_delta > 0:
|
|
242
|
+
improving.append({"title": "Protocol", "detail": f"{protocol_delta:+.1f}%", "tone": "healthy", "meta": "vs previous window"})
|
|
243
|
+
corrections_delta = trend.get("total_corrections_delta")
|
|
244
|
+
if isinstance(corrections_delta, int) and corrections_delta < 0:
|
|
245
|
+
improving.append({"title": "Corrections", "detail": f"{corrections_delta:+d}", "tone": "healthy", "meta": "lower is better"})
|
|
246
|
+
mood_delta = trend.get("avg_mood_delta")
|
|
247
|
+
if isinstance(mood_delta, (int, float)) and mood_delta > 0:
|
|
248
|
+
improving.append({"title": "Mood", "detail": f"{mood_delta:+.3f}", "tone": "healthy", "meta": "vs previous window"})
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"weekly": weekly,
|
|
252
|
+
"monthly": monthly,
|
|
253
|
+
"matters_now": matters_now[:4],
|
|
254
|
+
"drifting": drifting[:4],
|
|
255
|
+
"improving": improving[:4],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
159
259
|
# ---------------------------------------------------------------------------
|
|
160
260
|
# HTML page routes — Jinja2 with fallback to plain file
|
|
161
261
|
# ---------------------------------------------------------------------------
|
|
@@ -396,6 +496,30 @@ async def api_trust():
|
|
|
396
496
|
}
|
|
397
497
|
|
|
398
498
|
|
|
499
|
+
@app.get("/api/project-pulse")
|
|
500
|
+
async def api_project_pulse(kind: str = Query("weekly", pattern="^(weekly|monthly)$")):
|
|
501
|
+
"""Latest project pressure snapshot from Deep Sleep summaries."""
|
|
502
|
+
summary = _latest_periodic_summary(kind)
|
|
503
|
+
if not summary:
|
|
504
|
+
return JSONResponse({"error": f"No {kind} summary found"}, status_code=404)
|
|
505
|
+
return {
|
|
506
|
+
"kind": kind,
|
|
507
|
+
"label": summary.get("label"),
|
|
508
|
+
"project_pulse": summary.get("project_pulse", []),
|
|
509
|
+
"top_projects": summary.get("top_projects", []),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@app.get("/api/engineering-loop")
|
|
514
|
+
async def api_engineering_loop():
|
|
515
|
+
"""Dashboard narrative: what matters now, what is drifting, what is improving."""
|
|
516
|
+
weekly = _latest_periodic_summary("weekly")
|
|
517
|
+
monthly = _latest_periodic_summary("monthly")
|
|
518
|
+
if not weekly and not monthly:
|
|
519
|
+
return JSONResponse({"error": "No periodic Deep Sleep summaries found"}, status_code=404)
|
|
520
|
+
return _summarize_engineering_loop(weekly or {}, monthly or {})
|
|
521
|
+
|
|
522
|
+
|
|
399
523
|
@app.get("/api/adaptive")
|
|
400
524
|
async def api_adaptive():
|
|
401
525
|
"""Adaptive personality: current weight state + mode history."""
|
|
@@ -138,6 +138,30 @@
|
|
|
138
138
|
</div>
|
|
139
139
|
</div>
|
|
140
140
|
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Row 3: engineering loop narrative -->
|
|
143
|
+
<div class="grid grid-cols-3 gap-4">
|
|
144
|
+
<div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
|
|
145
|
+
<div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Matters Now</div>
|
|
146
|
+
<ul id="matters-now-list" class="space-y-2">
|
|
147
|
+
<li class="text-xs text-slate-600 py-1">loading...</li>
|
|
148
|
+
</ul>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
|
|
152
|
+
<div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Drifting</div>
|
|
153
|
+
<ul id="drifting-list" class="space-y-2">
|
|
154
|
+
<li class="text-xs text-slate-600 py-1">loading...</li>
|
|
155
|
+
</ul>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
|
|
159
|
+
<div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Improving</div>
|
|
160
|
+
<ul id="improving-list" class="space-y-2">
|
|
161
|
+
<li class="text-xs text-slate-600 py-1">loading...</li>
|
|
162
|
+
</ul>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
141
165
|
</div>
|
|
142
166
|
|
|
143
167
|
<!-- Quick Create Modal -->
|
|
@@ -244,7 +268,7 @@ async function submitQuickCreate(e) {
|
|
|
244
268
|
async function loadDashboardData() {
|
|
245
269
|
const today = getToday();
|
|
246
270
|
|
|
247
|
-
const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData] =
|
|
271
|
+
const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData, engineeringData] =
|
|
248
272
|
await Promise.all([
|
|
249
273
|
fetchJSON('/api/trust'),
|
|
250
274
|
fetchJSON('/api/stats'),
|
|
@@ -253,6 +277,7 @@ async function loadDashboardData() {
|
|
|
253
277
|
fetchJSON('/api/sessions?limit=3'),
|
|
254
278
|
fetchJSON('/api/watchdog'),
|
|
255
279
|
fetchJSON('/api/inbox/unread'),
|
|
280
|
+
fetchJSON('/api/engineering-loop'),
|
|
256
281
|
]);
|
|
257
282
|
|
|
258
283
|
// --- Trust Score (animated gauge) ---
|
|
@@ -426,6 +451,39 @@ async function loadDashboardData() {
|
|
|
426
451
|
badge.classList.add('flex');
|
|
427
452
|
}
|
|
428
453
|
}
|
|
454
|
+
|
|
455
|
+
// --- Engineering loop narrative ---
|
|
456
|
+
const toneClass = tone => {
|
|
457
|
+
if (tone === 'critical') return 'text-red-400';
|
|
458
|
+
if (tone === 'elevated' || tone === 'watch') return 'text-amber-400';
|
|
459
|
+
return 'text-emerald-400';
|
|
460
|
+
};
|
|
461
|
+
const renderNarrativeList = (id, items, emptyText) => {
|
|
462
|
+
const node = document.getElementById(id);
|
|
463
|
+
if (!node) return;
|
|
464
|
+
if (!items || items.length === 0) {
|
|
465
|
+
node.innerHTML = `<li class="text-xs text-slate-600 py-1">${escapeHtml(emptyText)}</li>`;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
node.innerHTML = items.map(item => `
|
|
469
|
+
<li class="border-b border-slate-800/30 pb-2 last:border-0 last:pb-0">
|
|
470
|
+
<div class="flex items-center justify-between gap-2">
|
|
471
|
+
<span class="text-xs text-slate-300">${escapeHtml(item.title || '--')}</span>
|
|
472
|
+
<span class="text-[10px] font-mono ${toneClass(item.tone)}">${escapeHtml(item.detail || '')}</span>
|
|
473
|
+
</div>
|
|
474
|
+
${item.meta ? `<div class="text-[10px] text-slate-600 mt-1">${escapeHtml(item.meta)}</div>` : ''}
|
|
475
|
+
</li>
|
|
476
|
+
`).join('');
|
|
477
|
+
};
|
|
478
|
+
if (engineeringData && !engineeringData.error) {
|
|
479
|
+
renderNarrativeList('matters-now-list', engineeringData.matters_now, 'No active pressure detected');
|
|
480
|
+
renderNarrativeList('drifting-list', engineeringData.drifting, 'No major drift detected');
|
|
481
|
+
renderNarrativeList('improving-list', engineeringData.improving, 'No improvement deltas yet');
|
|
482
|
+
} else {
|
|
483
|
+
renderNarrativeList('matters-now-list', [], 'No periodic summary available');
|
|
484
|
+
renderNarrativeList('drifting-list', [], 'No periodic summary available');
|
|
485
|
+
renderNarrativeList('improving-list', [], 'No periodic summary available');
|
|
486
|
+
}
|
|
429
487
|
}
|
|
430
488
|
|
|
431
489
|
loadDashboardData();
|