nexo-brain 4.0.1 → 5.0.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 +13 -10
- package/bin/nexo-brain.js +34 -9
- package/bin/nexo.js +29 -2
- package/package.json +1 -1
- package/src/auto_update.py +80 -15
- package/src/cli.py +110 -0
- package/src/crons/manifest.json +24 -0
- package/src/crons/sync.py +27 -11
- package/src/dashboard/app.py +48 -3
- package/src/dashboard/templates/cortex.html +86 -27
- package/src/db/__init__.py +33 -0
- package/src/db/_drive.py +318 -0
- package/src/db/_goal_profiles.py +376 -0
- package/src/db/_outcomes.py +800 -0
- package/src/db/_protocol.py +239 -2
- package/src/db/_reminders.py +148 -2
- package/src/db/_schema.py +141 -0
- package/src/db/_skills.py +264 -8
- package/src/doctor/providers/runtime.py +48 -1
- package/src/maintenance.py +3 -0
- package/src/plugins/cortex.py +702 -0
- package/src/plugins/episodic_memory.py +15 -2
- package/src/plugins/goal_engine.py +142 -0
- package/src/plugins/impact.py +29 -0
- package/src/plugins/outcomes.py +130 -0
- package/src/plugins/protocol.py +299 -0
- package/src/plugins/skills.py +19 -1
- package/src/plugins/update.py +39 -3
- package/src/scripts/deep-sleep/apply_findings.py +119 -3
- package/src/scripts/deep-sleep/synthesize-prompt.md +34 -0
- package/src/scripts/nexo-impact-scorer.py +117 -0
- package/src/scripts/nexo-outcome-checker.py +97 -0
- package/src/scripts/nexo-synthesis.py +81 -3
- package/src/server.py +62 -0
- package/src/skills_runtime.py +203 -0
- package/src/tools_drive.py +484 -0
- package/src/tools_reminders.py +3 -4
- package/src/tools_reminders_crud.py +15 -0
- package/src/tools_sessions.py +17 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.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
|
@@ -87,15 +87,14 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
|
|
|
87
87
|
- when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
|
|
88
88
|
- NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
|
|
89
89
|
|
|
90
|
-
Version `
|
|
90
|
+
Version `5.0.0` closes the loop between memory, decisions, outcomes, and reusable behavior:
|
|
91
91
|
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
- newer memory layers now declare an explicit backend contract rather than silently hardcoding storage assumptions forever
|
|
92
|
+
- goal profiles are now explicit and auditable instead of living as hidden heuristics
|
|
93
|
+
- the Cortex can rank alternatives with goals, outcomes, overrides, and structured penalties
|
|
94
|
+
- repeated outcome patterns can become durable learnings that influence later decisions
|
|
95
|
+
- outcome-backed evidence can seed, promote, demote, or retire reusable skills
|
|
96
|
+
- the runtime benchmark pack now shows the operator/runtime advantage with checked-in artifacts instead of relying only on prose
|
|
97
|
+
- personal-script/core runtime paths, protocol debt maintenance, and release doctoring are now strong enough that the live install path can be audited honestly before release
|
|
99
98
|
|
|
100
99
|
### Client Capability Matrix
|
|
101
100
|
|
|
@@ -625,6 +624,8 @@ Scripts in `NEXO_HOME/scripts/` are first-class managed entities:
|
|
|
625
624
|
|
|
626
625
|
Personal scripts are completely separate from core NEXO processes. The `crons/manifest.json` defines core; everything in `NEXO_HOME/scripts/` is personal.
|
|
627
626
|
|
|
627
|
+
If you need to decide between a personal script, skill, plugin, or schedule, use [docs/personal-artifacts-manual.md](docs/personal-artifacts-manual.md). That is the canonical operational guide.
|
|
628
|
+
|
|
628
629
|
## Recovery-Aware Background Jobs (v2.6.2)
|
|
629
630
|
|
|
630
631
|
Core and personal jobs now declare explicit recovery contracts in `crons/manifest.json`:
|
|
@@ -835,9 +836,9 @@ nexo doctor --tier runtime --json # Machine-readable health report
|
|
|
835
836
|
nexo doctor --fix # Apply deterministic repairs
|
|
836
837
|
```
|
|
837
838
|
|
|
838
|
-
Personal scripts live in `NEXO_HOME/scripts/` with inline metadata. Their Python templates now include `run_automation_text(...)`, which routes work through the configured NEXO automation backend instead of hardcoding `claude -p` or provider-specific model names. `nexo-agent-run.py` now also supports task profiles (`fast`, `balanced`, `deep`) plus safe backend fallback, so automations can prefer cheaper/faster Codex paths or deeper Claude paths without hardcoding one provider forever. See `docs/writing-scripts.md` for details.
|
|
839
|
+
Personal scripts live in `NEXO_HOME/scripts/` with inline metadata. Their Python templates now include `run_automation_text(...)`, which routes work through the configured NEXO automation backend instead of hardcoding `claude -p` or provider-specific model names. `nexo-agent-run.py` now also supports task profiles (`fast`, `balanced`, `deep`) plus safe backend fallback, so automations can prefer cheaper/faster Codex paths or deeper Claude paths without hardcoding one provider forever. See `docs/writing-scripts.md` for details and `docs/personal-artifacts-manual.md` for the canonical artifact decision guide.
|
|
839
840
|
|
|
840
|
-
Skills v2 combine procedural guides with optional executable scripts. Personal skills live in `NEXO_HOME/skills/`, packaged core skills live in `NEXO_CODE/skills/` during development and `NEXO_HOME/skills-core/` in installed environments, and staged runtime copies live in `NEXO_HOME/skills-runtime/`. Execution is fully autonomous: Deep Sleep can evolve mature guide skills into executable drafts automatically, and runtime execution no longer waits for manual approval. See `docs/skills-v2.md` for the full model.
|
|
841
|
+
Skills v2 combine procedural guides with optional executable scripts. Personal skills live in `NEXO_HOME/skills/`, packaged core skills live in `NEXO_CODE/skills/` during development and `NEXO_HOME/skills-core/` in installed environments, and staged runtime copies live in `NEXO_HOME/skills-runtime/`. Execution is fully autonomous: Deep Sleep can evolve mature guide skills into executable drafts automatically, and runtime execution no longer waits for manual approval. See `docs/skills-v2.md` for the full model and `docs/personal-artifacts-manual.md` for the boundary between skills, scripts, plugins, and schedules.
|
|
841
842
|
|
|
842
843
|
The Doctor system reads existing health artifacts (immune, watchdog, self-audit) without triggering repairs in default mode.
|
|
843
844
|
|
|
@@ -914,6 +915,8 @@ TOOLS = [
|
|
|
914
915
|
|
|
915
916
|
Reload without restarting: `nexo_plugin_load("my_plugin.py")`
|
|
916
917
|
|
|
918
|
+
Use a personal plugin only when you need a new MCP tool in the runtime surface. If the real need is autonomous execution or scheduling, use a personal script plus managed schedule instead. The canonical decision guide is [docs/personal-artifacts-manual.md](docs/personal-artifacts-manual.md).
|
|
919
|
+
|
|
917
920
|
### Data Privacy
|
|
918
921
|
|
|
919
922
|
- **Everything stays local.** All data in `~/.nexo/`, never uploaded anywhere.
|
package/bin/nexo-brain.js
CHANGED
|
@@ -2211,16 +2211,41 @@ async function main() {
|
|
|
2211
2211
|
"set -euo pipefail",
|
|
2212
2212
|
"",
|
|
2213
2213
|
`NEXO_HOME="${NEXO_HOME}"`,
|
|
2214
|
-
'PYTHON="$NEXO_HOME/.venv/bin/python3"',
|
|
2215
|
-
'if [ ! -x "$PYTHON" ]; then',
|
|
2216
|
-
' if command -v python3 >/dev/null 2>&1; then',
|
|
2217
|
-
' PYTHON="python3"',
|
|
2218
|
-
" else",
|
|
2219
|
-
' PYTHON="python"',
|
|
2220
|
-
" fi",
|
|
2221
|
-
"fi",
|
|
2222
2214
|
'export NEXO_HOME',
|
|
2223
|
-
'export NEXO_CODE="$NEXO_HOME"',
|
|
2215
|
+
'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"',
|
|
2216
|
+
'resolve_python() {',
|
|
2217
|
+
' local candidates=()',
|
|
2218
|
+
' local candidate=""',
|
|
2219
|
+
' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi',
|
|
2220
|
+
' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi',
|
|
2221
|
+
' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
|
|
2222
|
+
' case "$(uname -s)" in',
|
|
2223
|
+
' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;',
|
|
2224
|
+
' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;',
|
|
2225
|
+
' esac',
|
|
2226
|
+
' if command -v python3 >/dev/null 2>&1; then candidates+=("$(command -v python3)"); fi',
|
|
2227
|
+
' if command -v python >/dev/null 2>&1; then candidates+=("$(command -v python)"); fi',
|
|
2228
|
+
' for candidate in "${candidates[@]}"; do',
|
|
2229
|
+
' [ -n "$candidate" ] || continue',
|
|
2230
|
+
' [ -x "$candidate" ] || continue',
|
|
2231
|
+
' if NEXO_HOME="$NEXO_HOME" NEXO_CODE="$NEXO_CODE" "$candidate" -c "import fastmcp" >/dev/null 2>&1; then',
|
|
2232
|
+
' printf \'%s\\n\' "$candidate"',
|
|
2233
|
+
' return 0',
|
|
2234
|
+
' fi',
|
|
2235
|
+
' done',
|
|
2236
|
+
' for candidate in "${candidates[@]}"; do',
|
|
2237
|
+
' [ -n "$candidate" ] || continue',
|
|
2238
|
+
' [ -x "$candidate" ] || continue',
|
|
2239
|
+
' printf \'%s\\n\' "$candidate"',
|
|
2240
|
+
' return 0',
|
|
2241
|
+
' done',
|
|
2242
|
+
' return 1',
|
|
2243
|
+
'}',
|
|
2244
|
+
'PYTHON="$(resolve_python || true)"',
|
|
2245
|
+
'if [ -z "$PYTHON" ]; then',
|
|
2246
|
+
' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
|
|
2247
|
+
' exit 1',
|
|
2248
|
+
'fi',
|
|
2224
2249
|
'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"',
|
|
2225
2250
|
"",
|
|
2226
2251
|
].join("\n");
|
package/bin/nexo.js
CHANGED
|
@@ -14,17 +14,44 @@ const path = require("path");
|
|
|
14
14
|
|
|
15
15
|
const NEXO_HOME = process.env.NEXO_HOME || path.join(os.homedir(), ".nexo");
|
|
16
16
|
|
|
17
|
+
function pythonSupportsModule(candidate, moduleName) {
|
|
18
|
+
if (!candidate) return false;
|
|
19
|
+
if (candidate.includes("/") && !fs.existsSync(candidate)) return false;
|
|
20
|
+
try {
|
|
21
|
+
const result = spawnSync(candidate, ["-c", `import ${moduleName}`], {
|
|
22
|
+
stdio: "ignore",
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
NEXO_HOME,
|
|
26
|
+
NEXO_CODE: path.join(__dirname, "..", "src"),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return result.status === 0;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
function findPython() {
|
|
18
36
|
const candidates = [
|
|
37
|
+
process.env.NEXO_RUNTIME_PYTHON,
|
|
38
|
+
process.env.NEXO_PYTHON,
|
|
19
39
|
path.join(NEXO_HOME, ".venv", "bin", "python3"),
|
|
20
40
|
path.join(NEXO_HOME, ".venv", "bin", "python"),
|
|
41
|
+
process.platform === "darwin" ? "/opt/homebrew/bin/python3" : "",
|
|
42
|
+
"/usr/local/bin/python3",
|
|
43
|
+
"/usr/bin/python3",
|
|
21
44
|
"python3",
|
|
22
45
|
"python",
|
|
23
46
|
];
|
|
47
|
+
let fallback = "";
|
|
24
48
|
for (const c of candidates) {
|
|
25
|
-
if (c
|
|
49
|
+
if (!c) continue;
|
|
50
|
+
if (!(c.includes("/") ? fs.existsSync(c) : true)) continue;
|
|
51
|
+
if (!fallback) fallback = c;
|
|
52
|
+
if (pythonSupportsModule(c, "fastmcp")) return c;
|
|
26
53
|
}
|
|
27
|
-
return "python3";
|
|
54
|
+
return fallback || "python3";
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
function findCliPy() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.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
|
@@ -11,6 +11,7 @@ import json
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import os
|
|
13
13
|
import re
|
|
14
|
+
import shutil
|
|
14
15
|
import subprocess
|
|
15
16
|
import sys
|
|
16
17
|
import time
|
|
@@ -148,16 +149,41 @@ def _runtime_cli_wrapper_text() -> str:
|
|
|
148
149
|
"#!/usr/bin/env bash\n"
|
|
149
150
|
"set -euo pipefail\n\n"
|
|
150
151
|
f'NEXO_HOME="{NEXO_HOME}"\n'
|
|
151
|
-
'PYTHON="$NEXO_HOME/.venv/bin/python3"\n'
|
|
152
|
-
'if [ ! -x "$PYTHON" ]; then\n'
|
|
153
|
-
' if command -v python3 >/dev/null 2>&1; then\n'
|
|
154
|
-
' PYTHON="python3"\n'
|
|
155
|
-
" else\n"
|
|
156
|
-
' PYTHON="python"\n'
|
|
157
|
-
" fi\n"
|
|
158
|
-
"fi\n"
|
|
159
152
|
'export NEXO_HOME\n'
|
|
160
|
-
'export NEXO_CODE="$NEXO_HOME"\n'
|
|
153
|
+
'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"\n'
|
|
154
|
+
'resolve_python() {\n'
|
|
155
|
+
' local candidates=()\n'
|
|
156
|
+
' local candidate=""\n'
|
|
157
|
+
' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi\n'
|
|
158
|
+
' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi\n'
|
|
159
|
+
' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")\n'
|
|
160
|
+
' case "$(uname -s)" in\n'
|
|
161
|
+
' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;\n'
|
|
162
|
+
' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;\n'
|
|
163
|
+
' esac\n'
|
|
164
|
+
' if command -v python3 >/dev/null 2>&1; then candidates+=("$(command -v python3)"); fi\n'
|
|
165
|
+
' if command -v python >/dev/null 2>&1; then candidates+=("$(command -v python)"); fi\n'
|
|
166
|
+
' for candidate in "${candidates[@]}"; do\n'
|
|
167
|
+
' [ -n "$candidate" ] || continue\n'
|
|
168
|
+
' [ -x "$candidate" ] || continue\n'
|
|
169
|
+
' if NEXO_HOME="$NEXO_HOME" NEXO_CODE="$NEXO_CODE" "$candidate" -c "import fastmcp" >/dev/null 2>&1; then\n'
|
|
170
|
+
' printf \'%s\\n\' "$candidate"\n'
|
|
171
|
+
' return 0\n'
|
|
172
|
+
' fi\n'
|
|
173
|
+
' done\n'
|
|
174
|
+
' for candidate in "${candidates[@]}"; do\n'
|
|
175
|
+
' [ -n "$candidate" ] || continue\n'
|
|
176
|
+
' [ -x "$candidate" ] || continue\n'
|
|
177
|
+
' printf \'%s\\n\' "$candidate"\n'
|
|
178
|
+
' return 0\n'
|
|
179
|
+
' done\n'
|
|
180
|
+
' return 1\n'
|
|
181
|
+
'}\n'
|
|
182
|
+
'PYTHON="$(resolve_python || true)"\n'
|
|
183
|
+
'if [ -z "$PYTHON" ]; then\n'
|
|
184
|
+
' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
|
|
185
|
+
' exit 1\n'
|
|
186
|
+
'fi\n'
|
|
161
187
|
'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"\n'
|
|
162
188
|
)
|
|
163
189
|
|
|
@@ -209,14 +235,50 @@ def _requirements_hash() -> str:
|
|
|
209
235
|
return ""
|
|
210
236
|
|
|
211
237
|
|
|
238
|
+
def _venv_python_path(runtime_root: Path = NEXO_HOME) -> Path:
|
|
239
|
+
if sys.platform == "win32":
|
|
240
|
+
return runtime_root / ".venv" / "Scripts" / "python.exe"
|
|
241
|
+
return runtime_root / ".venv" / "bin" / "python3"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _venv_pip_path(runtime_root: Path = NEXO_HOME) -> Path:
|
|
245
|
+
if sys.platform == "win32":
|
|
246
|
+
return runtime_root / ".venv" / "Scripts" / "pip.exe"
|
|
247
|
+
return runtime_root / ".venv" / "bin" / "pip"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _ensure_runtime_venv(runtime_root: Path = NEXO_HOME) -> Path | None:
|
|
251
|
+
venv_python = _venv_python_path(runtime_root)
|
|
252
|
+
if venv_python.exists():
|
|
253
|
+
return venv_python
|
|
254
|
+
try:
|
|
255
|
+
runtime_root.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
result = subprocess.run(
|
|
257
|
+
[sys.executable, "-m", "venv", str(runtime_root / ".venv")],
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
timeout=120,
|
|
261
|
+
)
|
|
262
|
+
if result.returncode == 0 and venv_python.exists():
|
|
263
|
+
_log(f"Created managed venv at {runtime_root / '.venv'}")
|
|
264
|
+
return venv_python
|
|
265
|
+
_log(f"venv creation failed (exit {result.returncode}): {result.stderr or result.stdout}")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
_log(f"venv creation failed: {e}")
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
212
271
|
def _reinstall_pip_deps() -> bool:
|
|
213
272
|
"""Reinstall Python deps from requirements.txt. Returns True on success."""
|
|
214
273
|
req_file = SRC_DIR / "requirements.txt"
|
|
215
274
|
if not req_file.exists():
|
|
216
275
|
return True
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
276
|
+
_ensure_runtime_venv(NEXO_HOME)
|
|
277
|
+
venv_pip = _venv_pip_path(NEXO_HOME)
|
|
278
|
+
if not venv_pip.exists() and sys.platform != "win32":
|
|
279
|
+
alt_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
|
|
280
|
+
if alt_pip.exists():
|
|
281
|
+
venv_pip = alt_pip
|
|
220
282
|
try:
|
|
221
283
|
if venv_pip.exists():
|
|
222
284
|
result = subprocess.run(
|
|
@@ -1399,9 +1461,12 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
|
|
|
1399
1461
|
req_file = runtime_root / "requirements.txt"
|
|
1400
1462
|
if not req_file.exists():
|
|
1401
1463
|
return True
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1464
|
+
_ensure_runtime_venv(runtime_root)
|
|
1465
|
+
venv_pip = _venv_pip_path(runtime_root)
|
|
1466
|
+
if not venv_pip.exists() and sys.platform != "win32":
|
|
1467
|
+
alt_pip = runtime_root / ".venv" / "bin" / "pip3"
|
|
1468
|
+
if alt_pip.exists():
|
|
1469
|
+
venv_pip = alt_pip
|
|
1405
1470
|
try:
|
|
1406
1471
|
if venv_pip.exists():
|
|
1407
1472
|
result = subprocess.run(
|
package/src/cli.py
CHANGED
|
@@ -471,6 +471,94 @@ def _scripts_doctor(args):
|
|
|
471
471
|
return 0
|
|
472
472
|
|
|
473
473
|
|
|
474
|
+
def _runtime_python_candidates() -> list[str]:
|
|
475
|
+
candidates: list[str] = []
|
|
476
|
+
seen: set[str] = set()
|
|
477
|
+
|
|
478
|
+
def _add(value: str | None) -> None:
|
|
479
|
+
if not value:
|
|
480
|
+
return
|
|
481
|
+
text = str(value).strip()
|
|
482
|
+
if not text or text in seen:
|
|
483
|
+
return
|
|
484
|
+
seen.add(text)
|
|
485
|
+
candidates.append(text)
|
|
486
|
+
|
|
487
|
+
_add(os.environ.get("NEXO_RUNTIME_PYTHON"))
|
|
488
|
+
_add(os.environ.get("NEXO_PYTHON"))
|
|
489
|
+
_add(sys.executable)
|
|
490
|
+
|
|
491
|
+
for root in {NEXO_HOME, NEXO_CODE, NEXO_CODE.parent}:
|
|
492
|
+
_add(str(root / ".venv" / "bin" / "python3"))
|
|
493
|
+
_add(str(root / ".venv" / "bin" / "python"))
|
|
494
|
+
|
|
495
|
+
if sys.platform == "darwin":
|
|
496
|
+
_add("/opt/homebrew/bin/python3")
|
|
497
|
+
_add("/usr/local/bin/python3")
|
|
498
|
+
else:
|
|
499
|
+
_add("/usr/local/bin/python3")
|
|
500
|
+
_add("/usr/bin/python3")
|
|
501
|
+
|
|
502
|
+
_add(shutil.which("python3"))
|
|
503
|
+
_add(shutil.which("python"))
|
|
504
|
+
return candidates
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _python_supports_module(python_bin: str, module_name: str) -> bool:
|
|
508
|
+
path = Path(python_bin)
|
|
509
|
+
if "/" in python_bin and not path.exists():
|
|
510
|
+
return False
|
|
511
|
+
try:
|
|
512
|
+
result = subprocess.run(
|
|
513
|
+
[python_bin, "-c", f"import {module_name}"],
|
|
514
|
+
capture_output=True,
|
|
515
|
+
text=True,
|
|
516
|
+
timeout=5,
|
|
517
|
+
env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
|
|
518
|
+
)
|
|
519
|
+
return result.returncode == 0
|
|
520
|
+
except Exception:
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _recover_scripts_call_runtime(tool_name: str, exc: ModuleNotFoundError) -> int | None:
|
|
525
|
+
missing = getattr(exc, "name", "") or ""
|
|
526
|
+
if missing != "fastmcp":
|
|
527
|
+
return None
|
|
528
|
+
if os.environ.get("NEXO_CLI_REEXECED") == "1":
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
current = str(Path(sys.executable).resolve())
|
|
532
|
+
for candidate in _runtime_python_candidates():
|
|
533
|
+
try:
|
|
534
|
+
resolved = str(Path(candidate).resolve()) if "/" in candidate else candidate
|
|
535
|
+
except Exception:
|
|
536
|
+
resolved = candidate
|
|
537
|
+
if resolved == current:
|
|
538
|
+
continue
|
|
539
|
+
if not _python_supports_module(candidate, "fastmcp"):
|
|
540
|
+
continue
|
|
541
|
+
env = {
|
|
542
|
+
**os.environ,
|
|
543
|
+
"NEXO_HOME": str(NEXO_HOME),
|
|
544
|
+
"NEXO_CODE": str(NEXO_CODE),
|
|
545
|
+
"NEXO_CLI_REEXECED": "1",
|
|
546
|
+
}
|
|
547
|
+
result = subprocess.run(
|
|
548
|
+
[candidate, str(Path(__file__).resolve()), *sys.argv[1:]],
|
|
549
|
+
capture_output=True,
|
|
550
|
+
text=True,
|
|
551
|
+
timeout=60,
|
|
552
|
+
env=env,
|
|
553
|
+
)
|
|
554
|
+
if result.stdout:
|
|
555
|
+
print(result.stdout, end="")
|
|
556
|
+
if result.stderr:
|
|
557
|
+
print(result.stderr, file=sys.stderr, end="")
|
|
558
|
+
return result.returncode
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
474
562
|
def _scripts_call(args):
|
|
475
563
|
"""Call a NEXO MCP tool via in-process fastmcp client."""
|
|
476
564
|
tool_name = args.tool
|
|
@@ -562,6 +650,10 @@ def _scripts_call(args):
|
|
|
562
650
|
print(str(e), file=sys.stderr)
|
|
563
651
|
return 1
|
|
564
652
|
except Exception as e:
|
|
653
|
+
if isinstance(e, ModuleNotFoundError):
|
|
654
|
+
recovered = _recover_scripts_call_runtime(tool_name, e)
|
|
655
|
+
if recovered is not None:
|
|
656
|
+
return recovered
|
|
565
657
|
print(f"Error calling tool {tool_name}: {e}", file=sys.stderr)
|
|
566
658
|
return 1
|
|
567
659
|
|
|
@@ -1120,6 +1212,17 @@ def _skills_evolution(args):
|
|
|
1120
1212
|
return 0
|
|
1121
1213
|
|
|
1122
1214
|
|
|
1215
|
+
def _skills_outcome_review(args):
|
|
1216
|
+
from skills_runtime import review_skill_outcomes
|
|
1217
|
+
|
|
1218
|
+
result = review_skill_outcomes(args.id, auto_apply=args.auto_apply)
|
|
1219
|
+
if args.json:
|
|
1220
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1221
|
+
else:
|
|
1222
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1223
|
+
return 0 if result.get("ok") else 1
|
|
1224
|
+
|
|
1225
|
+
|
|
1123
1226
|
def _skills_promote(args):
|
|
1124
1227
|
from skills_runtime import promote_skill
|
|
1125
1228
|
|
|
@@ -1344,6 +1447,11 @@ def main():
|
|
|
1344
1447
|
skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
|
|
1345
1448
|
skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1346
1449
|
|
|
1450
|
+
skills_outcome_review_p = skills_sub.add_parser("outcome-review", help="Review skill lifecycle against sustained outcomes")
|
|
1451
|
+
skills_outcome_review_p.add_argument("id", help="Skill ID")
|
|
1452
|
+
skills_outcome_review_p.add_argument("--auto-apply", action="store_true", help="Apply the recommended promotion/retirement when it is strong enough")
|
|
1453
|
+
skills_outcome_review_p.add_argument("--json", action="store_true", help="JSON output")
|
|
1454
|
+
|
|
1347
1455
|
skills_promote_p = skills_sub.add_parser("promote", help="Promote a skill lifecycle level")
|
|
1348
1456
|
skills_promote_p.add_argument("id", help="Skill ID")
|
|
1349
1457
|
skills_promote_p.add_argument("--target-level", default="published", choices=["draft", "published", "stable"])
|
|
@@ -1445,6 +1553,8 @@ def main():
|
|
|
1445
1553
|
return _skills_featured(args)
|
|
1446
1554
|
elif args.skills_command == "evolution":
|
|
1447
1555
|
return _skills_evolution(args)
|
|
1556
|
+
elif args.skills_command == "outcome-review":
|
|
1557
|
+
return _skills_outcome_review(args)
|
|
1448
1558
|
elif args.skills_command == "promote":
|
|
1449
1559
|
return _skills_promote(args)
|
|
1450
1560
|
elif args.skills_command == "retire":
|
package/src/crons/manifest.json
CHANGED
|
@@ -143,6 +143,30 @@
|
|
|
143
143
|
"run_on_boot": true,
|
|
144
144
|
"run_on_wake": true
|
|
145
145
|
},
|
|
146
|
+
{
|
|
147
|
+
"id": "impact-scorer",
|
|
148
|
+
"script": "scripts/nexo-impact-scorer.py",
|
|
149
|
+
"schedule": {"hour": 5, "minute": 45},
|
|
150
|
+
"description": "Daily impact scoring for active followups so real queues can prioritize by expected impact",
|
|
151
|
+
"core": true,
|
|
152
|
+
"recovery_policy": "catchup",
|
|
153
|
+
"idempotent": true,
|
|
154
|
+
"max_catchup_age": 172800,
|
|
155
|
+
"run_on_boot": true,
|
|
156
|
+
"run_on_wake": true
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "outcome-checker",
|
|
160
|
+
"script": "scripts/nexo-outcome-checker.py",
|
|
161
|
+
"schedule": {"hour": 8, "minute": 0},
|
|
162
|
+
"description": "Daily outcome verification — marks tracked outcomes as met or missed",
|
|
163
|
+
"core": true,
|
|
164
|
+
"recovery_policy": "catchup",
|
|
165
|
+
"idempotent": true,
|
|
166
|
+
"max_catchup_age": 172800,
|
|
167
|
+
"run_on_boot": true,
|
|
168
|
+
"run_on_wake": true
|
|
169
|
+
},
|
|
146
170
|
{
|
|
147
171
|
"id": "auto-close-sessions",
|
|
148
172
|
"script": "auto_close_sessions.py",
|
package/src/crons/sync.py
CHANGED
|
@@ -42,6 +42,9 @@ LABEL_PREFIX = "com.nexo."
|
|
|
42
42
|
LOG_DIR = NEXO_HOME / "logs"
|
|
43
43
|
OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
|
|
44
44
|
SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
|
|
45
|
+
CORE_CRON_MANAGED_ENV = "NEXO_MANAGED_CORE_CRON"
|
|
46
|
+
PERSONAL_CRON_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
|
|
47
|
+
PERSONAL_CRON_ID_ENV = "NEXO_PERSONAL_CRON_ID"
|
|
45
48
|
RETIRED_CORE_FILES = (
|
|
46
49
|
Path("scripts") / "nexo-day-orchestrator.sh",
|
|
47
50
|
)
|
|
@@ -316,6 +319,29 @@ def unload_plist(plist_path: Path, dry_run: bool):
|
|
|
316
319
|
log(f" Removed: {plist_path.name}")
|
|
317
320
|
|
|
318
321
|
|
|
322
|
+
def _plist_is_personal(existing: dict) -> bool:
|
|
323
|
+
"""Return True when a LaunchAgent is explicitly managed as a personal cron."""
|
|
324
|
+
env = existing.get("EnvironmentVariables", {}) or {}
|
|
325
|
+
return env.get(PERSONAL_CRON_MANAGED_ENV) == "1" or bool(env.get(PERSONAL_CRON_ID_ENV))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _plist_is_core(existing: dict) -> bool:
|
|
329
|
+
"""Return True when a LaunchAgent should be treated as a core cron."""
|
|
330
|
+
env = existing.get("EnvironmentVariables", {}) or {}
|
|
331
|
+
if _plist_is_personal(existing):
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
if env.get(CORE_CRON_MANAGED_ENV) == "1":
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
args = existing.get("ProgramArguments", [])
|
|
338
|
+
arg_blob = " ".join(str(a) for a in args)
|
|
339
|
+
return (
|
|
340
|
+
"nexo-cron-wrapper.sh" in arg_blob
|
|
341
|
+
and (str(SOURCE_ROOT) in arg_blob or str(NEXO_HOME) in arg_blob)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
319
345
|
def sync(dry_run: bool = False):
|
|
320
346
|
system = platform.system()
|
|
321
347
|
if system == "Linux":
|
|
@@ -355,20 +381,10 @@ def sync(dry_run: bool = False):
|
|
|
355
381
|
# (personal crons like shopify-backup, email-monitor are left alone)
|
|
356
382
|
for cron_id, plist_path in installed.items():
|
|
357
383
|
if cron_id not in manifest_ids:
|
|
358
|
-
# Check if this was previously a core cron by reading the plist
|
|
359
|
-
# If it points to NEXO_CODE scripts → it's core, safe to remove
|
|
360
384
|
try:
|
|
361
385
|
with open(plist_path, "rb") as f:
|
|
362
386
|
existing = plistlib.load(f)
|
|
363
|
-
|
|
364
|
-
args = existing.get("ProgramArguments", [])
|
|
365
|
-
is_core = env.get("NEXO_MANAGED_CORE_CRON") == "1"
|
|
366
|
-
if not is_core:
|
|
367
|
-
arg_blob = " ".join(str(a) for a in args)
|
|
368
|
-
is_core = (
|
|
369
|
-
"nexo-cron-wrapper.sh" in arg_blob
|
|
370
|
-
and (str(SOURCE_ROOT) in arg_blob or str(NEXO_HOME) in arg_blob)
|
|
371
|
-
)
|
|
387
|
+
is_core = _plist_is_core(existing)
|
|
372
388
|
except Exception:
|
|
373
389
|
is_core = False
|
|
374
390
|
|
package/src/dashboard/app.py
CHANGED
|
@@ -1566,9 +1566,54 @@ async def api_guard(limit: int = Query(100, ge=1, le=500)):
|
|
|
1566
1566
|
async def api_cortex(limit: int = Query(50, ge=1, le=200)):
|
|
1567
1567
|
db = _db()
|
|
1568
1568
|
conn = db.get_db()
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1569
|
+
summary = db.cortex_evaluation_summary(days=30) if hasattr(db, "cortex_evaluation_summary") else {}
|
|
1570
|
+
logs = []
|
|
1571
|
+
decisions = []
|
|
1572
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_log'").fetchone():
|
|
1573
|
+
logs = [dict(r) for r in conn.execute("SELECT * FROM cortex_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()]
|
|
1574
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='decisions'").fetchone():
|
|
1575
|
+
decisions = [dict(r) for r in conn.execute("SELECT * FROM decisions ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()]
|
|
1576
|
+
evaluations = []
|
|
1577
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_evaluations'").fetchone():
|
|
1578
|
+
evaluations = [
|
|
1579
|
+
dict(r) for r in conn.execute(
|
|
1580
|
+
"SELECT * FROM cortex_evaluations ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
1581
|
+
(limit,),
|
|
1582
|
+
).fetchall()
|
|
1583
|
+
]
|
|
1584
|
+
for item in evaluations:
|
|
1585
|
+
for key, default in (("goal_profile_labels", []), ("goal_profile_weights", {})):
|
|
1586
|
+
raw = item.get(key)
|
|
1587
|
+
if raw in (None, ""):
|
|
1588
|
+
item[key] = default
|
|
1589
|
+
continue
|
|
1590
|
+
if isinstance(raw, (list, dict)):
|
|
1591
|
+
continue
|
|
1592
|
+
try:
|
|
1593
|
+
item[key] = json.loads(raw)
|
|
1594
|
+
except Exception:
|
|
1595
|
+
item[key] = default
|
|
1596
|
+
linked_outcome_ids = sorted(
|
|
1597
|
+
{
|
|
1598
|
+
int(item.get("linked_outcome_id"))
|
|
1599
|
+
for item in evaluations
|
|
1600
|
+
if item.get("linked_outcome_id")
|
|
1601
|
+
}
|
|
1602
|
+
)
|
|
1603
|
+
if linked_outcome_ids and conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='outcomes'").fetchone():
|
|
1604
|
+
placeholders = ",".join("?" for _ in linked_outcome_ids)
|
|
1605
|
+
outcome_rows = {
|
|
1606
|
+
int(row["id"]): dict(row)
|
|
1607
|
+
for row in conn.execute(
|
|
1608
|
+
f"SELECT id, status, deadline FROM outcomes WHERE id IN ({placeholders})",
|
|
1609
|
+
linked_outcome_ids,
|
|
1610
|
+
).fetchall()
|
|
1611
|
+
}
|
|
1612
|
+
for item in evaluations:
|
|
1613
|
+
outcome_id = item.get("linked_outcome_id")
|
|
1614
|
+
if outcome_id:
|
|
1615
|
+
item["linked_outcome"] = outcome_rows.get(int(outcome_id))
|
|
1616
|
+
return {"cortex_logs": logs, "decisions": decisions, "evaluations": evaluations, "summary": summary}
|
|
1572
1617
|
|
|
1573
1618
|
|
|
1574
1619
|
# ---------------------------------------------------------------------------
|