nexo-brain 6.0.1 → 6.0.3
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 +6 -1
- package/bin/nexo-brain.js +28 -1
- package/package.json +1 -1
- package/src/agent_runner.py +19 -3
- package/src/auto_update.py +62 -0
- package/src/plugins/guard.py +55 -2
- package/src/resonance_map.py +83 -10
- package/src/scripts/nexo-agent-run.py +16 -1
- package/templates/nexo_helper.py +24 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.3",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `6.0.
|
|
21
|
+
Version `6.0.3` is the current packaged-runtime line: double-fix release. (1) `resonance_tiers.json` is now published to `~/.nexo/brain/resonance_tiers.json` — the public contract path defined by v6.0.0 and consumed by NEXO Desktop ≥ 0.12.0. Pre-v6.0.3 the installer wrote it to `~/.nexo/resonance_tiers.json`, so Desktop failed to start Claude with *"NEXO Brain contract missing"* on every fresh install and every update from 6.0.0 / 6.0.1 / 6.0.2 unless the user copied the file by hand. An idempotent migration in `auto_update.py` promotes legacy runtimes on `nexo update`. (2) `nexo_guard_check` now persists the caller's SID on every `guard_checks` row (env `NEXO_SID` → env `CLAUDE_SESSION_ID` via `sessions.external_session_id` → most-recently-updated `sessions` row). Pre-v6.0.3 it hardcoded `session_id=""`, so `hook_guardrails._session_has_guard_check` never matched and strict-protocol sessions tripped *"no guard_check seen for this session"* after every successful call.
|
|
22
|
+
|
|
23
|
+
Previously in `6.0.2`: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` → explicit `reasoning_effort=` → `calibration.preferences.default_resonance` → `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
|
|
24
|
+
|
|
25
|
+
Previously in `6.0.1`: hotfix on top of the 6.0.0 release. `protocol_settings.py` now treats the process as interactive when **either** stdin+stdout are TTYs **or** `NEXO_INTERACTIVE=1` is exported — closes the gap where NEXO Desktop 0.12.0 spawned `claude` through pipes and Brain fell back to `lenient` even with a human in the loop. The `PostToolUse` hook also gains an inbox autodetect stage: when the session has unread `nexo_send` messages and has gone 60s+ without a heartbeat, it emits a `systemMessage` asking the agent to run `nexo_heartbeat` and consume them. Rate-limited to one reminder per minute per SID (new `hook_inbox_reminders` table, migration m42). Added `sessions.last_heartbeat_ts`, stamped by every successful heartbeat. `NEXO_INTERACTIVE` is an internal Brain↔Electron contract — not user-facing, not a resurrection of the removed `NEXO_PROTOCOL_STRICTNESS`.
|
|
22
26
|
|
|
23
27
|
Previously in `6.0.0`: **BREAKING** tier-only setup. Onboarding asks for one resonance tier (`maximo`/`alto`/`medio`/`bajo`) and that choice drives every backend via `src/resonance_tiers.json`; the per-backend model/effort prompts are gone and the legacy `client_runtime_profiles.{claude_code,codex}.{model,reasoning_effort}` are silently purged from `schedule.json` on upgrade. Protocol strictness is no longer configurable — interactive TTY sessions run `strict`, non-TTY (crons, pipes, tests) run `lenient`; `NEXO_PROTOCOL_STRICTNESS` env, `preferences.protocol_strictness`, and the `default/normal/off/warn/soft` aliases are all removed. `preferences.show_pending_at_start` moves to NEXO Desktop's electron-store. The seven core hooks are now unified behind `src/hooks/manifest.json` (plugin and npm modes read the same file), two new hooks ship (`Notification` for live-session activity and `SubagentStop` for auto-closing stale `protocol_tasks`), and `auto_capture.py` is wired to both `UserPromptSubmit` and `PostToolUse` with a persistent 1h dedup table plus an automatic `nexo_learning_add` on correction matches. `~/.nexo/hooks_status.json` is published after every `registerAllCoreHooks()` so NEXO Desktop ≥0.12.0 can render Hooks activos X/Y. New `nexo-brain --skip` flag aliases `--yes`/`--defaults`. Full suite 1057 passed, 1 skipped.
|
|
24
28
|
|
|
@@ -1145,6 +1149,7 @@ If NEXO Brain is useful to you, consider:
|
|
|
1145
1149
|
- **Share your experience** — tell others how you're using cognitive memory in your AI workflows
|
|
1146
1150
|
- **Contribute** — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Issues and PRs welcome
|
|
1147
1151
|
- **Client parity / shared-brain maintenance** — see [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
|
|
1152
|
+
- **Writing a personal script that calls the automation backend** — see [docs/personal-scripts-guide.md](docs/personal-scripts-guide.md)
|
|
1148
1153
|
|
|
1149
1154
|
[](https://star-history.com/#wazionapps/nexo&Date)
|
|
1150
1155
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -235,7 +235,6 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
|
|
|
235
235
|
"runtime_power.py",
|
|
236
236
|
"requirements.txt",
|
|
237
237
|
"model_defaults.json",
|
|
238
|
-
"resonance_tiers.json",
|
|
239
238
|
];
|
|
240
239
|
const discoveredRootModules = fs.existsSync(srcDir)
|
|
241
240
|
? fs.readdirSync(srcDir)
|
|
@@ -257,6 +256,29 @@ function getCoreRuntimePackages() {
|
|
|
257
256
|
return ["db", "cognitive", "doctor"];
|
|
258
257
|
}
|
|
259
258
|
|
|
259
|
+
// Brain contracts — files the NEXO Brain publishes to consumers like
|
|
260
|
+
// NEXO Desktop under ~/.nexo/brain/. These are NOT code (they live outside
|
|
261
|
+
// getCoreRuntimeFlatFiles for that reason) but data contracts with a stable
|
|
262
|
+
// path that external clients read. Keep in sync with docs/contracts/.
|
|
263
|
+
function publishBrainContracts(srcDir = path.join(__dirname, "..", "src"), nexoHome = NEXO_HOME) {
|
|
264
|
+
const brainDir = path.join(nexoHome, "brain");
|
|
265
|
+
fs.mkdirSync(brainDir, { recursive: true });
|
|
266
|
+
const contracts = ["resonance_tiers.json"];
|
|
267
|
+
contracts.forEach((name) => {
|
|
268
|
+
const src = path.join(srcDir, name);
|
|
269
|
+
if (fs.existsSync(src)) {
|
|
270
|
+
fs.copyFileSync(src, path.join(brainDir, name));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
// Clean up legacy location: before v6.0.3 the file was written to
|
|
274
|
+
// NEXO_HOME/resonance_tiers.json. Remove it so only the contract path
|
|
275
|
+
// remains authoritative.
|
|
276
|
+
const legacy = path.join(nexoHome, "resonance_tiers.json");
|
|
277
|
+
if (fs.existsSync(legacy)) {
|
|
278
|
+
try { fs.unlinkSync(legacy); } catch (_) { /* best-effort */ }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
260
282
|
function resolveLaunchAgentPath(home) {
|
|
261
283
|
const parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
|
|
262
284
|
path.join(home, ".local/bin"), path.join(home, ".nexo/bin")];
|
|
@@ -1654,6 +1676,8 @@ async function main() {
|
|
|
1654
1676
|
copyDirRec(pkgSrc, path.join(NEXO_HOME, pkg));
|
|
1655
1677
|
}
|
|
1656
1678
|
});
|
|
1679
|
+
// Publish Brain contracts to ~/.nexo/brain/ (read by NEXO Desktop et al.)
|
|
1680
|
+
publishBrainContracts(srcDir, NEXO_HOME);
|
|
1657
1681
|
log(" Core files updated.");
|
|
1658
1682
|
|
|
1659
1683
|
// Reconcile Python dependencies after updating code (mirrors fresh-install logic)
|
|
@@ -2490,6 +2514,9 @@ async function main() {
|
|
|
2490
2514
|
}
|
|
2491
2515
|
});
|
|
2492
2516
|
|
|
2517
|
+
// Publish Brain contracts to ~/.nexo/brain/ (read by NEXO Desktop et al.)
|
|
2518
|
+
publishBrainContracts(srcDir, NEXO_HOME);
|
|
2519
|
+
|
|
2493
2520
|
// Runtime CLI wrapper lives in NEXO_HOME/bin so it survives npx installs.
|
|
2494
2521
|
const runtimeCli = [
|
|
2495
2522
|
"#!/usr/bin/env bash",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.3",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain \u2014 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
|
@@ -531,6 +531,7 @@ def run_automation_interactive(
|
|
|
531
531
|
env: dict | None = None,
|
|
532
532
|
preferences: dict | None = None,
|
|
533
533
|
session_type: str = "interactive_chat",
|
|
534
|
+
tier: str = "",
|
|
534
535
|
) -> subprocess.CompletedProcess:
|
|
535
536
|
"""Launch an interactive Claude/Codex session with automation_runs logging.
|
|
536
537
|
|
|
@@ -564,8 +565,13 @@ def run_automation_interactive(
|
|
|
564
565
|
user_default = ""
|
|
565
566
|
if isinstance(prefs, dict):
|
|
566
567
|
user_default = str(prefs.get("default_resonance") or "").strip()
|
|
568
|
+
# v6.0.2 — respect explicit ``tier`` override so personal/* callers
|
|
569
|
+
# can force a resonance without registering in the core map.
|
|
570
|
+
explicit_tier_arg = (tier or "").strip() or None
|
|
567
571
|
resonance_tier = resolve_tier_for_caller(
|
|
568
|
-
caller,
|
|
572
|
+
caller,
|
|
573
|
+
user_default=user_default or None,
|
|
574
|
+
explicit_tier=explicit_tier_arg,
|
|
569
575
|
)
|
|
570
576
|
except Exception:
|
|
571
577
|
resonance_tier = ""
|
|
@@ -848,6 +854,7 @@ def run_automation_prompt(
|
|
|
848
854
|
env: dict | None = None,
|
|
849
855
|
model: str = "",
|
|
850
856
|
reasoning_effort: str = "",
|
|
857
|
+
tier: str = "",
|
|
851
858
|
timeout: int = 300,
|
|
852
859
|
output_format: str = "",
|
|
853
860
|
append_system_prompt: str = "",
|
|
@@ -891,13 +898,22 @@ def run_automation_prompt(
|
|
|
891
898
|
user_default = ""
|
|
892
899
|
if isinstance(prefs, dict):
|
|
893
900
|
user_default = str(prefs.get("default_resonance") or "").strip()
|
|
901
|
+
# v6.0.2 — ``tier`` kwarg propagates to the resolver as ``explicit_tier``
|
|
902
|
+
# so personal/* callers can pin their reasoning budget per-invocation
|
|
903
|
+
# without editing the registry. Registered callers remain unchanged.
|
|
904
|
+
explicit_tier_arg = (tier or "").strip() or None
|
|
894
905
|
# This raises UnregisteredCallerError if caller is unknown — the
|
|
895
906
|
# same fail-closed rule we wanted. No silent fallback.
|
|
896
907
|
resonance_tier = resolve_tier_for_caller(
|
|
897
|
-
caller,
|
|
908
|
+
caller,
|
|
909
|
+
user_default=user_default or None,
|
|
910
|
+
explicit_tier=explicit_tier_arg,
|
|
898
911
|
)
|
|
899
912
|
mapped_model, mapped_effort = resolve_model_and_effort(
|
|
900
|
-
caller,
|
|
913
|
+
caller,
|
|
914
|
+
selected_backend,
|
|
915
|
+
user_default=user_default or None,
|
|
916
|
+
explicit_tier=explicit_tier_arg,
|
|
901
917
|
)
|
|
902
918
|
if mapped_model and not model:
|
|
903
919
|
model = mapped_model
|
package/src/auto_update.py
CHANGED
|
@@ -973,6 +973,57 @@ def _migrate_effort_to_resonance(dest: Path = NEXO_HOME) -> list[str]:
|
|
|
973
973
|
return actions
|
|
974
974
|
|
|
975
975
|
|
|
976
|
+
def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
|
|
977
|
+
"""Ensure ``resonance_tiers.json`` lives at the public contract path
|
|
978
|
+
``NEXO_HOME/brain/resonance_tiers.json`` and purge the legacy copy at
|
|
979
|
+
``NEXO_HOME/resonance_tiers.json``.
|
|
980
|
+
|
|
981
|
+
Context: v6.0.0 defined the public contract (read by NEXO Desktop) as
|
|
982
|
+
``~/.nexo/brain/resonance_tiers.json`` but the installer kept copying
|
|
983
|
+
the file to ``~/.nexo/resonance_tiers.json`` (legacy flat-file layout),
|
|
984
|
+
so Desktop failed with *"NEXO Brain contract missing"* until the user
|
|
985
|
+
moved the file by hand. v6.0.3 publishes straight to ``brain/`` and
|
|
986
|
+
this migration reconciles existing runtimes.
|
|
987
|
+
|
|
988
|
+
Idempotent: no-op once the contract file is in ``brain/`` and the
|
|
989
|
+
legacy file is gone. Never raises — migration must not block an update.
|
|
990
|
+
"""
|
|
991
|
+
actions: list[str] = []
|
|
992
|
+
brain_dir = dest / "brain"
|
|
993
|
+
contract_path = brain_dir / "resonance_tiers.json"
|
|
994
|
+
legacy_path = dest / "resonance_tiers.json"
|
|
995
|
+
|
|
996
|
+
try:
|
|
997
|
+
brain_dir.mkdir(parents=True, exist_ok=True)
|
|
998
|
+
except Exception as exc:
|
|
999
|
+
actions.append(f"resonance-contract-relocate-warning:mkdir:{exc.__class__.__name__}")
|
|
1000
|
+
return actions
|
|
1001
|
+
|
|
1002
|
+
# If the contract already exists in brain/, just drop the legacy copy.
|
|
1003
|
+
if contract_path.is_file():
|
|
1004
|
+
if legacy_path.is_file():
|
|
1005
|
+
try:
|
|
1006
|
+
legacy_path.unlink()
|
|
1007
|
+
actions.append("resonance-contract-relocate:legacy-removed")
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
actions.append(f"resonance-contract-relocate-warning:unlink:{exc.__class__.__name__}")
|
|
1010
|
+
return actions
|
|
1011
|
+
|
|
1012
|
+
# Contract missing from brain/ — promote the legacy file if present.
|
|
1013
|
+
if legacy_path.is_file():
|
|
1014
|
+
try:
|
|
1015
|
+
contract_path.write_bytes(legacy_path.read_bytes())
|
|
1016
|
+
legacy_path.unlink()
|
|
1017
|
+
actions.append("resonance-contract-relocate:moved-to-brain")
|
|
1018
|
+
except Exception as exc:
|
|
1019
|
+
actions.append(f"resonance-contract-relocate-warning:move:{exc.__class__.__name__}")
|
|
1020
|
+
# If neither exists, the caller (nexo-brain.js publishBrainContracts)
|
|
1021
|
+
# will write it from the package source on the next install pass; nothing
|
|
1022
|
+
# for this Python migration to do.
|
|
1023
|
+
|
|
1024
|
+
return actions
|
|
1025
|
+
|
|
1026
|
+
|
|
976
1027
|
def _bootstrap_profile_from_calibration_meta(dest: Path = NEXO_HOME) -> list[str]:
|
|
977
1028
|
"""Create ``brain/profile.json`` from ``calibration.json`` fields when the
|
|
978
1029
|
profile file does not exist yet.
|
|
@@ -2894,6 +2945,17 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
2894
2945
|
except Exception as exc:
|
|
2895
2946
|
actions.append(f"profile-bootstrap-warning:{exc.__class__.__name__}")
|
|
2896
2947
|
|
|
2948
|
+
# v6.0.3 — relocate resonance_tiers.json from NEXO_HOME root (pre-v6.0.3
|
|
2949
|
+
# layout) to NEXO_HOME/brain/ (public contract path consumed by
|
|
2950
|
+
# NEXO Desktop). Idempotent; safe no-op once the move is done.
|
|
2951
|
+
try:
|
|
2952
|
+
_emit_progress(progress_fn, "Relocating resonance_tiers contract to brain/...")
|
|
2953
|
+
reloc_actions = _relocate_resonance_tiers_contract(dest)
|
|
2954
|
+
for action in reloc_actions:
|
|
2955
|
+
actions.append(action)
|
|
2956
|
+
except Exception as exc:
|
|
2957
|
+
actions.append(f"resonance-contract-relocate-warning:{exc.__class__.__name__}")
|
|
2958
|
+
|
|
2897
2959
|
# v6.0.0 purge — drop legacy fields that moved elsewhere in v6.
|
|
2898
2960
|
# client_runtime_profiles.*.{model,reasoning_effort} → resonance_tiers.json.
|
|
2899
2961
|
# preferences.protocol_strictness → TTY/no-TTY detection.
|
package/src/plugins/guard.py
CHANGED
|
@@ -11,6 +11,55 @@ from pathlib import Path
|
|
|
11
11
|
from db import get_db, find_similar_learnings, extract_keywords, search_learnings, search_changes
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _resolve_active_sid(conn) -> str:
|
|
15
|
+
"""Resolve the SID of the caller invoking this guard check.
|
|
16
|
+
|
|
17
|
+
Order of precedence:
|
|
18
|
+
1. ``NEXO_SID`` env var (headless crons + wrapped CLI set this).
|
|
19
|
+
2. ``CLAUDE_SESSION_ID`` env var translated via
|
|
20
|
+
``sessions.external_session_id`` / ``sessions.claude_session_id``.
|
|
21
|
+
3. The most-recently-updated session in ``sessions`` (fallback for
|
|
22
|
+
MCP tool calls where the broker doesn't forward env vars but a
|
|
23
|
+
single active session exists).
|
|
24
|
+
|
|
25
|
+
Returns an empty string if nothing resolves. The caller MUST handle
|
|
26
|
+
the empty-string case explicitly — persisting ``session_id=""`` was
|
|
27
|
+
the v6.0.2 bug that left ``missing_file_guard`` blind to successful
|
|
28
|
+
guard checks.
|
|
29
|
+
"""
|
|
30
|
+
env_sid = (os.environ.get("NEXO_SID") or "").strip()
|
|
31
|
+
if env_sid.startswith("nexo-"):
|
|
32
|
+
return env_sid
|
|
33
|
+
|
|
34
|
+
external_candidates = [env_sid] if env_sid else []
|
|
35
|
+
claude_sid = (os.environ.get("CLAUDE_SESSION_ID") or "").strip()
|
|
36
|
+
if claude_sid:
|
|
37
|
+
external_candidates.append(claude_sid)
|
|
38
|
+
for cand in external_candidates:
|
|
39
|
+
try:
|
|
40
|
+
row = conn.execute(
|
|
41
|
+
"SELECT sid FROM sessions "
|
|
42
|
+
"WHERE external_session_id = ? OR claude_session_id = ? "
|
|
43
|
+
"ORDER BY last_update_epoch DESC LIMIT 1",
|
|
44
|
+
(cand, cand),
|
|
45
|
+
).fetchone()
|
|
46
|
+
except Exception:
|
|
47
|
+
row = None
|
|
48
|
+
if row and row["sid"]:
|
|
49
|
+
return str(row["sid"])
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
row = conn.execute(
|
|
53
|
+
"SELECT sid FROM sessions "
|
|
54
|
+
"ORDER BY last_update_epoch DESC LIMIT 1"
|
|
55
|
+
).fetchone()
|
|
56
|
+
except Exception:
|
|
57
|
+
row = None
|
|
58
|
+
if row and row["sid"]:
|
|
59
|
+
return str(row["sid"])
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
14
63
|
def _split_applies_to(applies_to: str) -> list[str]:
|
|
15
64
|
return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
|
|
16
65
|
|
|
@@ -370,11 +419,15 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
|
|
|
370
419
|
(time.time(), lid)
|
|
371
420
|
)
|
|
372
421
|
|
|
373
|
-
# Log the guard check
|
|
422
|
+
# Log the guard check — v6.0.3 fix: resolve the caller's SID instead of
|
|
423
|
+
# inserting the empty string. Pre-v6.0.3 every row landed with
|
|
424
|
+
# session_id='' so missing_file_guard could not find the guard call
|
|
425
|
+
# from hook_guardrails._session_has_guard_check and blocked forever.
|
|
426
|
+
active_sid = _resolve_active_sid(conn)
|
|
374
427
|
conn.execute(
|
|
375
428
|
"INSERT INTO guard_checks (session_id, files, area, learnings_returned, blocking_rules_returned) "
|
|
376
429
|
"VALUES (?, ?, ?, ?, ?)",
|
|
377
|
-
(
|
|
430
|
+
(active_sid, files, area, len(result["learnings"]) + len(result["universal_rules"]),
|
|
378
431
|
len(result["blocking_rules"]))
|
|
379
432
|
)
|
|
380
433
|
conn.commit()
|
package/src/resonance_map.py
CHANGED
|
@@ -46,6 +46,7 @@ future scripts from silently inheriting the wrong tier.
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
48
|
import json
|
|
49
|
+
import os
|
|
49
50
|
from pathlib import Path
|
|
50
51
|
from typing import Tuple
|
|
51
52
|
|
|
@@ -65,7 +66,27 @@ from typing import Tuple
|
|
|
65
66
|
|
|
66
67
|
TIERS = ("maximo", "alto", "medio", "bajo")
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
# Resolution order for the contract file:
|
|
70
|
+
# 1) ~/.nexo/brain/resonance_tiers.json (v6.0.3+ public contract path, shared
|
|
71
|
+
# with NEXO Desktop and any external client)
|
|
72
|
+
# 2) Legacy NEXO_HOME/resonance_tiers.json (pre-v6.0.3 runtime layout)
|
|
73
|
+
# 3) Package source src/resonance_tiers.json (dev checkouts / tests)
|
|
74
|
+
def _resolve_resonance_path() -> Path:
|
|
75
|
+
nexo_home = os.environ.get("NEXO_HOME") or str(Path.home() / ".nexo")
|
|
76
|
+
candidates = [
|
|
77
|
+
Path(nexo_home) / "brain" / "resonance_tiers.json",
|
|
78
|
+
Path(nexo_home) / "resonance_tiers.json",
|
|
79
|
+
Path(__file__).resolve().parent / "resonance_tiers.json",
|
|
80
|
+
]
|
|
81
|
+
for candidate in candidates:
|
|
82
|
+
if candidate.is_file():
|
|
83
|
+
return candidate
|
|
84
|
+
# No contract present: return the canonical path so the ValueError raised
|
|
85
|
+
# at load time points at where the contract should live.
|
|
86
|
+
return candidates[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_RESONANCE_JSON_PATH = _resolve_resonance_path()
|
|
69
90
|
|
|
70
91
|
|
|
71
92
|
def _normalize_tier_entry(entry: dict) -> dict[str, tuple[str, str]]:
|
|
@@ -213,6 +234,21 @@ ALL_REGISTERED_CALLERS: frozenset[str] = frozenset(
|
|
|
213
234
|
)
|
|
214
235
|
|
|
215
236
|
|
|
237
|
+
# v6.0.2 — Reserved caller prefix for user-owned personal scripts that live
|
|
238
|
+
# outside this repo (``~/.nexo/scripts/``). Callers matching this prefix
|
|
239
|
+
# bypass the registry entirely: they cannot be required to register because
|
|
240
|
+
# they ship with each operator's own install. Instead, the script passes
|
|
241
|
+
# either an explicit ``tier`` (semantic) or a ``reasoning_effort`` (direct
|
|
242
|
+
# override) — or falls back to the user's ``default_resonance`` preference,
|
|
243
|
+
# and finally to ``DEFAULT_RESONANCE`` as the last line of defence.
|
|
244
|
+
#
|
|
245
|
+
# The prefix is NOT a loophole for new core scripts. Anything inside the
|
|
246
|
+
# ``src/`` tree or shipped via the core manifest continues to require a
|
|
247
|
+
# registered entry. The docs (``docs/personal-scripts-guide.md``) explain
|
|
248
|
+
# the split to any NEXO session helping an operator author a new script.
|
|
249
|
+
PERSONAL_CALLER_PREFIX = "personal/"
|
|
250
|
+
|
|
251
|
+
|
|
216
252
|
class UnregisteredCallerError(ValueError):
|
|
217
253
|
"""Raised when a caller string is not in the resonance registry.
|
|
218
254
|
|
|
@@ -271,22 +307,55 @@ def _load_user_default_resonance() -> str:
|
|
|
271
307
|
return ""
|
|
272
308
|
|
|
273
309
|
|
|
274
|
-
def
|
|
275
|
-
"""
|
|
310
|
+
def _normalise_tier(candidate: str | None) -> str:
|
|
311
|
+
"""Coerce a tier string to canonical lowercase; empty when invalid."""
|
|
312
|
+
if not candidate:
|
|
313
|
+
return ""
|
|
314
|
+
value = str(candidate).strip().lower()
|
|
315
|
+
return value if value in TIERS else ""
|
|
276
316
|
|
|
277
|
-
- User-facing callers resolve to ``user_default`` (or ``DEFAULT_RESONANCE``
|
|
278
|
-
if the user has no preference recorded).
|
|
279
|
-
- System-owned callers resolve to their fixed tier.
|
|
280
|
-
- Unknown callers raise ``UnregisteredCallerError``.
|
|
281
317
|
|
|
282
|
-
|
|
283
|
-
|
|
318
|
+
def resolve_tier_for_caller(
|
|
319
|
+
caller: str,
|
|
320
|
+
user_default: str | None = None,
|
|
321
|
+
*,
|
|
322
|
+
explicit_tier: str | None = None,
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Return the resonance tier that should apply to ``caller``.
|
|
325
|
+
|
|
326
|
+
Resolution order:
|
|
327
|
+
|
|
328
|
+
1. ``caller`` is empty → raise ``UnregisteredCallerError`` (same as v6.0.0).
|
|
329
|
+
2. ``caller`` starts with ``PERSONAL_CALLER_PREFIX``:
|
|
330
|
+
a. ``explicit_tier`` if valid — semantic override from the script.
|
|
331
|
+
b. ``user_default`` if valid — operator's configured default.
|
|
332
|
+
c. Stored ``preferences.default_resonance`` via the loader.
|
|
333
|
+
d. ``DEFAULT_RESONANCE`` as the final fallback.
|
|
334
|
+
The registry is NEVER consulted for personal callers: scripts outside
|
|
335
|
+
the repo cannot register, and forcing them to pin a tier there would
|
|
336
|
+
defeat the whole purpose of the ``personal/`` contract.
|
|
337
|
+
3. User-facing callers: user default → DEFAULT (unchanged).
|
|
338
|
+
4. System-owned callers: fixed tier (unchanged).
|
|
339
|
+
5. Anything else: ``UnregisteredCallerError`` (unchanged).
|
|
284
340
|
"""
|
|
285
341
|
if not caller:
|
|
286
342
|
raise UnregisteredCallerError(
|
|
287
343
|
"caller= is required. Every automation subprocess must be registered "
|
|
288
344
|
"in src/resonance_map.py so its reasoning budget is deliberate."
|
|
289
345
|
)
|
|
346
|
+
|
|
347
|
+
if caller.startswith(PERSONAL_CALLER_PREFIX):
|
|
348
|
+
explicit = _normalise_tier(explicit_tier)
|
|
349
|
+
if explicit:
|
|
350
|
+
return explicit
|
|
351
|
+
from_user = _normalise_tier(user_default)
|
|
352
|
+
if from_user:
|
|
353
|
+
return from_user
|
|
354
|
+
from_prefs = _normalise_tier(_load_user_default_resonance())
|
|
355
|
+
if from_prefs:
|
|
356
|
+
return from_prefs
|
|
357
|
+
return DEFAULT_RESONANCE
|
|
358
|
+
|
|
290
359
|
if caller in USER_FACING_CALLERS:
|
|
291
360
|
resolved_default = user_default
|
|
292
361
|
if resolved_default is None:
|
|
@@ -308,6 +377,8 @@ def resolve_model_and_effort(
|
|
|
308
377
|
caller: str,
|
|
309
378
|
backend: str,
|
|
310
379
|
user_default: str | None = None,
|
|
380
|
+
*,
|
|
381
|
+
explicit_tier: str | None = None,
|
|
311
382
|
) -> Tuple[str, str]:
|
|
312
383
|
"""Return ``(model, reasoning_effort)`` for ``caller`` on ``backend``.
|
|
313
384
|
|
|
@@ -316,7 +387,9 @@ def resolve_model_and_effort(
|
|
|
316
387
|
empty pair; the caller is expected to handle that by raising or by
|
|
317
388
|
passing its own explicit model/effort arguments.
|
|
318
389
|
"""
|
|
319
|
-
tier = resolve_tier_for_caller(
|
|
390
|
+
tier = resolve_tier_for_caller(
|
|
391
|
+
caller, user_default=user_default, explicit_tier=explicit_tier
|
|
392
|
+
)
|
|
320
393
|
backend_entry = _RESONANCE_TABLE.get(tier, {}).get(backend)
|
|
321
394
|
if backend_entry is None:
|
|
322
395
|
return "", ""
|
|
@@ -33,6 +33,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
33
33
|
parser.add_argument("--task-profile", default="", help="Automation task profile: default|fast|balanced|deep")
|
|
34
34
|
parser.add_argument("--model", default="", help="Backend model hint")
|
|
35
35
|
parser.add_argument("--reasoning-effort", default="", help="Backend reasoning effort/profile")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--tier",
|
|
38
|
+
default="",
|
|
39
|
+
help="Resonance tier — 'maximo'/'alto'/'medio'/'bajo'. "
|
|
40
|
+
"v6.0.2+ — used by personal/* callers to override the "
|
|
41
|
+
"resonance without editing src/resonance_map.py.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--caller",
|
|
45
|
+
default="",
|
|
46
|
+
help="Registered caller id (e.g. nexo_chat) or 'personal/<id>' for "
|
|
47
|
+
"user-owned scripts. Required in practice; empty falls back "
|
|
48
|
+
"to 'agent_run/generic' for backward compatibility.",
|
|
49
|
+
)
|
|
36
50
|
parser.add_argument("--timeout", type=int, default=AUTOMATION_SUBPROCESS_TIMEOUT, help="Timeout in seconds")
|
|
37
51
|
parser.add_argument("--output-format", default="text", help="Requested output format")
|
|
38
52
|
parser.add_argument("--allowed-tools", default="", help="Claude-style allowed tools contract")
|
|
@@ -52,11 +66,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
52
66
|
try:
|
|
53
67
|
result = run_automation_prompt(
|
|
54
68
|
prompt,
|
|
55
|
-
caller=
|
|
69
|
+
caller=args.caller or "agent_run/generic",
|
|
56
70
|
cwd=args.cwd or None,
|
|
57
71
|
task_profile=args.task_profile,
|
|
58
72
|
model=args.model,
|
|
59
73
|
reasoning_effort=args.reasoning_effort,
|
|
74
|
+
tier=args.tier,
|
|
60
75
|
timeout=args.timeout,
|
|
61
76
|
output_format=args.output_format,
|
|
62
77
|
append_system_prompt=append_system_prompt,
|
package/templates/nexo_helper.py
CHANGED
|
@@ -206,18 +206,29 @@ def run_automation_text(
|
|
|
206
206
|
allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
|
|
207
207
|
append_system_prompt: str = "",
|
|
208
208
|
include_bootstrap: bool = True,
|
|
209
|
+
caller: str = "",
|
|
210
|
+
tier: str = "",
|
|
209
211
|
) -> str:
|
|
210
212
|
"""Run the configured NEXO automation backend and return text output.
|
|
211
213
|
|
|
212
214
|
This avoids hardcoding provider CLIs such as `claude -p` inside personal
|
|
213
215
|
scripts. The runtime routes the call through the selected backend and its
|
|
214
216
|
configured model profile.
|
|
217
|
+
|
|
218
|
+
Personal scripts (those living in ``~/.nexo/scripts/``) should pass
|
|
219
|
+
``caller="personal/<descriptive-id>"`` and optionally ``tier="alto"``
|
|
220
|
+
(or another canonical tier) to pick their resonance without editing the
|
|
221
|
+
NEXO Brain repo. See ``docs/personal-scripts-guide.md`` for the rules.
|
|
215
222
|
"""
|
|
216
223
|
runner = NEXO_HOME / "scripts" / "nexo-agent-run.py"
|
|
217
224
|
if not runner.exists():
|
|
218
225
|
raise RuntimeError(f"Automation runner not found: {runner}")
|
|
219
226
|
|
|
220
227
|
cmd = [sys.executable, str(runner), "--prompt", prompt, "--output-format", "text"]
|
|
228
|
+
if caller:
|
|
229
|
+
cmd.extend(["--caller", caller])
|
|
230
|
+
if tier:
|
|
231
|
+
cmd.extend(["--tier", tier])
|
|
221
232
|
if model:
|
|
222
233
|
cmd.extend(["--model", model])
|
|
223
234
|
if reasoning_effort:
|
|
@@ -261,13 +272,25 @@ def run_automation_json(
|
|
|
261
272
|
allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
|
|
262
273
|
append_system_prompt: str = "",
|
|
263
274
|
include_bootstrap: bool = True,
|
|
275
|
+
caller: str = "",
|
|
276
|
+
tier: str = "",
|
|
264
277
|
) -> dict:
|
|
265
|
-
"""Run the configured backend and return a parsed JSON object.
|
|
278
|
+
"""Run the configured backend and return a parsed JSON object.
|
|
279
|
+
|
|
280
|
+
v6.0.2 adds ``caller`` and ``tier`` kwargs so personal scripts
|
|
281
|
+
(``~/.nexo/scripts/``) can identify themselves and pick a resonance
|
|
282
|
+
without registering in the core repo. See
|
|
283
|
+
``docs/personal-scripts-guide.md``.
|
|
284
|
+
"""
|
|
266
285
|
runner = NEXO_HOME / "scripts" / "nexo-agent-run.py"
|
|
267
286
|
if not runner.exists():
|
|
268
287
|
raise RuntimeError(f"Automation runner not found: {runner}")
|
|
269
288
|
|
|
270
289
|
cmd = [sys.executable, str(runner), "--prompt", prompt, "--output-format", "json"]
|
|
290
|
+
if caller:
|
|
291
|
+
cmd.extend(["--caller", caller])
|
|
292
|
+
if tier:
|
|
293
|
+
cmd.extend(["--tier", tier])
|
|
271
294
|
if model:
|
|
272
295
|
cmd.extend(["--model", model])
|
|
273
296
|
if reasoning_effort:
|