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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -10
  3. package/bin/nexo-brain.js +34 -9
  4. package/bin/nexo.js +29 -2
  5. package/package.json +1 -1
  6. package/src/auto_update.py +80 -15
  7. package/src/cli.py +110 -0
  8. package/src/crons/manifest.json +24 -0
  9. package/src/crons/sync.py +27 -11
  10. package/src/dashboard/app.py +48 -3
  11. package/src/dashboard/templates/cortex.html +86 -27
  12. package/src/db/__init__.py +33 -0
  13. package/src/db/_drive.py +318 -0
  14. package/src/db/_goal_profiles.py +376 -0
  15. package/src/db/_outcomes.py +800 -0
  16. package/src/db/_protocol.py +239 -2
  17. package/src/db/_reminders.py +148 -2
  18. package/src/db/_schema.py +141 -0
  19. package/src/db/_skills.py +264 -8
  20. package/src/doctor/providers/runtime.py +48 -1
  21. package/src/maintenance.py +3 -0
  22. package/src/plugins/cortex.py +702 -0
  23. package/src/plugins/episodic_memory.py +15 -2
  24. package/src/plugins/goal_engine.py +142 -0
  25. package/src/plugins/impact.py +29 -0
  26. package/src/plugins/outcomes.py +130 -0
  27. package/src/plugins/protocol.py +299 -0
  28. package/src/plugins/skills.py +19 -1
  29. package/src/plugins/update.py +39 -3
  30. package/src/scripts/deep-sleep/apply_findings.py +119 -3
  31. package/src/scripts/deep-sleep/synthesize-prompt.md +34 -0
  32. package/src/scripts/nexo-impact-scorer.py +117 -0
  33. package/src/scripts/nexo-outcome-checker.py +97 -0
  34. package/src/scripts/nexo-synthesis.py +81 -3
  35. package/src/server.py +62 -0
  36. package/src/skills_runtime.py +203 -0
  37. package/src/tools_drive.py +484 -0
  38. package/src/tools_reminders.py +3 -4
  39. package/src/tools_reminders_crud.py +15 -0
  40. package/src/tools_sessions.py +17 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "4.0.1",
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 `4.0.1` keeps the 4.0 release aligned across channels while preserving the next memory-surface gap closure:
90
+ Version `5.0.0` closes the loop between memory, decisions, outcomes, and reusable behavior:
91
91
 
92
- - non-text artifacts now have a first-class multimodal reference layer instead of living outside the memory model
93
- - pre-compaction auto-flush now persists actionable session state before context compression can erase it
94
- - the claim graph now behaves like a public knowledge wiki with evidence, freshness, verification state, and linting
95
- - operators can export a readable markdown memory bundle instead of trusting only opaque database state
96
- - user adaptation now uses a richer inspectable user-state model instead of leaning only on shallow sentiment heuristics
97
- - retrieval exposes more public knobs for hybrid weighting, decomposition, dreams, and dormant-memory handling
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.includes("/") ? fs.existsSync(c) : true) return 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": "4.0.1",
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",
@@ -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
- venv_pip = NEXO_HOME / ".venv" / "bin" / "pip"
218
- if not venv_pip.exists():
219
- venv_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
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
- venv_pip = runtime_root / ".venv" / "bin" / "pip"
1403
- if not venv_pip.exists():
1404
- venv_pip = runtime_root / ".venv" / "bin" / "pip3"
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":
@@ -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
- env = existing.get("EnvironmentVariables", {}) or {}
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
 
@@ -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
- logs = [dict(r) for r in conn.execute("SELECT * FROM cortex_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()]
1570
- decisions = [dict(r) for r in conn.execute("SELECT * FROM decisions ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()]
1571
- return {"cortex_logs": logs, "decisions": decisions}
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
  # ---------------------------------------------------------------------------