nexo-brain 6.0.0 → 6.0.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
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.0` is the current packaged-runtime line: **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.
21
+ Version `6.0.2` is the current packaged-runtime line: 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).
22
+
23
+ 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`.
24
+
25
+ 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.
22
26
 
23
27
  Previously in `5.10.2`: auto-bootstraps `brain/profile.json` from `brain/calibration.json` on `nexo update` when the profile file is missing, empty, or corrupt AND calibration carries at least one of `meta.role`, `meta.technical_level`, `name`, `language`. NEXO Desktop's *Preferencias → Avanzado* tab used to render an empty `{}` for that block when the onboarding flow had been interrupted; now it either shows the seeded profile or a friendly explanation of what each file is for, paired with Desktop `v0.11.2` which adds header descriptions to both JSON blocks. Never overwrites a populated profile, never raises, idempotent. Also fixes a latent host-filesystem leak in `test_user_facing_caller_with_no_user_default_uses_alto` exposed by the v5.10.1 migration.
24
28
 
@@ -1143,6 +1147,7 @@ If NEXO Brain is useful to you, consider:
1143
1147
  - **Share your experience** — tell others how you're using cognitive memory in your AI workflows
1144
1148
  - **Contribute** — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Issues and PRs welcome
1145
1149
  - **Client parity / shared-brain maintenance** — see [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
1150
+ - **Writing a personal script that calls the automation backend** — see [docs/personal-scripts-guide.md](docs/personal-scripts-guide.md)
1146
1151
 
1147
1152
  [![Star History Chart](https://api.star-history.com/svg?repos=wazionapps/nexo&type=Date)](https://star-history.com/#wazionapps/nexo&Date)
1148
1153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
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",
@@ -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, user_default=user_default or None
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, user_default=user_default or None
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, selected_backend, user_default=user_default or None
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
@@ -81,6 +81,14 @@ from db._sessions import (
81
81
  track_files, untrack_files, get_all_tracked_files,
82
82
  send_message, get_inbox,
83
83
  ask_question, answer_question, get_pending_questions, check_answer,
84
+ update_last_heartbeat_ts, get_last_heartbeat_ts,
85
+ count_pending_inbox_messages, resolve_sid_from_external,
86
+ )
87
+
88
+ # PostToolUse inbox-reminder rate limit (v6.0.1)
89
+ _hook_inbox_reminders = _load_submodule("db._hook_inbox_reminders")
90
+ from db._hook_inbox_reminders import (
91
+ get_last_reminder_ts, mark_reminder_sent, reset_reminders_for_sid,
84
92
  )
85
93
 
86
94
  # Reminders and followups
@@ -0,0 +1,73 @@
1
+ """NEXO DB — Hook inbox reminder bookkeeping (v6.0.1).
2
+
3
+ The ``PostToolUse`` hook may surface a ``systemMessage`` that tells the
4
+ agent it has unread ``nexo_send`` messages when the session has been
5
+ autopiloting through tool calls for a while. This module backs the rate
6
+ limit: at most one reminder per minute per SID, stored in the tiny
7
+ ``hook_inbox_reminders`` table created by migration m42.
8
+
9
+ All helpers are best-effort on the read path and raise on unexpected
10
+ write failures — callers (the hook itself) wrap calls in try/except so
11
+ a malformed DB never breaks the tool pipeline.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from db._core import get_db
16
+
17
+
18
+ def get_last_reminder_ts(sid: str) -> float | None:
19
+ """Return the epoch seconds of the last inbox reminder for ``sid``.
20
+
21
+ Returns None when no row exists yet. Never raises — treats any
22
+ unexpected error as "no prior reminder recorded" so the hook can
23
+ decide to emit a fresh one.
24
+ """
25
+ if not sid:
26
+ return None
27
+ try:
28
+ row = get_db().execute(
29
+ "SELECT last_reminder_ts FROM hook_inbox_reminders WHERE sid = ?",
30
+ (sid,),
31
+ ).fetchone()
32
+ except Exception:
33
+ return None
34
+ if row is None:
35
+ return None
36
+ try:
37
+ return float(row[0]) if row[0] is not None else None
38
+ except (TypeError, ValueError):
39
+ return None
40
+
41
+
42
+ def mark_reminder_sent(sid: str, ts: float) -> None:
43
+ """Record that a reminder was surfaced for ``sid`` at ``ts``.
44
+
45
+ Uses SQLite UPSERT so the table tracks one row per SID. Silently
46
+ swallows DB errors; the hook caller logs / skips as needed.
47
+ """
48
+ if not sid:
49
+ return
50
+ try:
51
+ conn = get_db()
52
+ conn.execute(
53
+ "INSERT INTO hook_inbox_reminders (sid, last_reminder_ts) "
54
+ "VALUES (?, ?) "
55
+ "ON CONFLICT(sid) DO UPDATE SET last_reminder_ts = excluded.last_reminder_ts",
56
+ (sid, float(ts)),
57
+ )
58
+ conn.commit()
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ def reset_reminders_for_sid(sid: str) -> None:
64
+ """Delete the reminder row for ``sid``. Used by tests that want to
65
+ start from a clean slate between assertions."""
66
+ if not sid:
67
+ return
68
+ try:
69
+ conn = get_db()
70
+ conn.execute("DELETE FROM hook_inbox_reminders WHERE sid = ?", (sid,))
71
+ conn.commit()
72
+ except Exception:
73
+ pass
package/src/db/_schema.py CHANGED
@@ -1010,6 +1010,37 @@ def _m41_automation_sessions_columns(conn):
1010
1010
  )
1011
1011
 
1012
1012
 
1013
+ def _m42_v6_0_1_hotfix(conn):
1014
+ """v6.0.1 hotfix — last_heartbeat_ts on sessions + hook_inbox_reminders.
1015
+
1016
+ Two surfaces:
1017
+
1018
+ 1. ``sessions.last_heartbeat_ts`` is a REAL column holding the epoch
1019
+ seconds of the most recent ``nexo_heartbeat`` call for that SID.
1020
+ The PostToolUse hook uses it to decide whether to emit an
1021
+ inbox-reminder systemMessage on autopilot sessions that have not
1022
+ checked their inbox in a while.
1023
+
1024
+ 2. ``hook_inbox_reminders`` is a tiny table storing the last time we
1025
+ surfaced an inbox reminder per SID. The hook reads/writes it to
1026
+ enforce a rate limit of at most one reminder per minute per
1027
+ session, so long streams of tool calls do not spam the user.
1028
+
1029
+ Idempotent by construction: ``_migrate_add_column`` is a no-op when
1030
+ the column exists, ``CREATE TABLE IF NOT EXISTS`` likewise.
1031
+ """
1032
+ _migrate_add_column(conn, "sessions", "last_heartbeat_ts", "REAL")
1033
+ _migrate_add_index(
1034
+ conn, "idx_sessions_last_heartbeat_ts", "sessions", "last_heartbeat_ts"
1035
+ )
1036
+ conn.execute(
1037
+ """CREATE TABLE IF NOT EXISTS hook_inbox_reminders (
1038
+ sid TEXT PRIMARY KEY,
1039
+ last_reminder_ts REAL NOT NULL
1040
+ )"""
1041
+ )
1042
+
1043
+
1013
1044
  MIGRATIONS = [
1014
1045
  (1, "learnings_columns", _m1_learnings_columns),
1015
1046
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1052,6 +1083,7 @@ MIGRATIONS = [
1052
1083
  (39, "hook_runs", _m39_hook_runs),
1053
1084
  (40, "classification_columns", _m40_classification_columns),
1054
1085
  (41, "automation_sessions_columns", _m41_automation_sessions_columns),
1086
+ (42, "v6_0_1_hotfix", _m42_v6_0_1_hotfix),
1055
1087
  ]
1056
1088
 
1057
1089
 
@@ -123,6 +123,110 @@ def clean_stale_sessions() -> int:
123
123
  return count
124
124
 
125
125
 
126
+ def update_last_heartbeat_ts(sid: str, ts: float | None = None) -> None:
127
+ """Stamp ``sessions.last_heartbeat_ts`` with the current heartbeat time.
128
+
129
+ Added in v6.0.1 so the PostToolUse hook can decide whether an
130
+ autopilot session has gone long enough without a heartbeat to
131
+ deserve an inbox reminder. Called from ``handle_heartbeat`` after
132
+ every successful heartbeat. Never raises — treats a missing
133
+ session row (test harnesses, race on cleanup) as a no-op.
134
+ """
135
+ if not sid:
136
+ return
137
+ try:
138
+ sid = _validate_sid(sid)
139
+ except Exception:
140
+ return
141
+ stamp = float(ts) if ts is not None else now_epoch()
142
+ conn = get_db()
143
+ try:
144
+ conn.execute(
145
+ "UPDATE sessions SET last_heartbeat_ts = ? WHERE sid = ?",
146
+ (stamp, sid),
147
+ )
148
+ conn.commit()
149
+ except Exception:
150
+ pass
151
+
152
+
153
+ def get_last_heartbeat_ts(sid: str) -> float | None:
154
+ """Return the epoch seconds of the most recent heartbeat for ``sid``.
155
+
156
+ Returns None when either the session does not exist or the column
157
+ has never been populated (pre-v6.0.1 rows, or a brand-new session
158
+ that has not yet called ``nexo_heartbeat``). The hook treats None
159
+ as "too new to reason about" and skips the reminder.
160
+ """
161
+ if not sid:
162
+ return None
163
+ conn = get_db()
164
+ try:
165
+ row = conn.execute(
166
+ "SELECT last_heartbeat_ts FROM sessions WHERE sid = ?", (sid,)
167
+ ).fetchone()
168
+ except Exception:
169
+ return None
170
+ if not row:
171
+ return None
172
+ try:
173
+ return float(row["last_heartbeat_ts"]) if row["last_heartbeat_ts"] is not None else None
174
+ except (TypeError, ValueError, KeyError):
175
+ return None
176
+
177
+
178
+ def count_pending_inbox_messages(sid: str) -> int:
179
+ """Count unread ``messages`` addressed to ``sid`` (direct or broadcast).
180
+
181
+ The concrete read-tracking table is ``message_reads``; a message is
182
+ "pending" when no row in ``message_reads`` matches ``(message_id, sid)``
183
+ and the message is not self-sent. Added in v6.0.1 for the PostToolUse
184
+ inbox-autodetect reminder.
185
+ """
186
+ if not sid:
187
+ return 0
188
+ try:
189
+ row = get_db().execute(
190
+ "SELECT COUNT(*) FROM messages m "
191
+ "WHERE (m.to_sid = 'all' OR m.to_sid = ?) "
192
+ "AND m.from_sid != ? "
193
+ "AND m.id NOT IN (SELECT message_id FROM message_reads WHERE sid = ?)",
194
+ (sid, sid, sid),
195
+ ).fetchone()
196
+ except Exception:
197
+ return 0
198
+ if not row:
199
+ return 0
200
+ try:
201
+ return int(row[0])
202
+ except (TypeError, ValueError):
203
+ return 0
204
+
205
+
206
+ def resolve_sid_from_external(external_id: str) -> str:
207
+ """Map a Claude Code ``session_id`` back to the NEXO SID we track.
208
+
209
+ The PostToolUse hook payload carries the external Claude session id;
210
+ we want the internal SID to query the messages table. Returns an
211
+ empty string when no session matches or the external id is empty.
212
+ """
213
+ external = (external_id or "").strip()
214
+ if not external:
215
+ return ""
216
+ try:
217
+ conn = get_db()
218
+ row = conn.execute(
219
+ "SELECT sid FROM sessions WHERE external_session_id = ? OR claude_session_id = ? "
220
+ "ORDER BY last_update_epoch DESC LIMIT 1",
221
+ (external, external),
222
+ ).fetchone()
223
+ except Exception:
224
+ return ""
225
+ if row and row["sid"]:
226
+ return str(row["sid"])
227
+ return ""
228
+
229
+
126
230
  def search_sessions(keyword: str) -> list[dict]:
127
231
  """Find sessions whose task contains keyword (case-insensitive)."""
128
232
  conn = get_db()
@@ -7,6 +7,12 @@ heartbeat-posttool). Also pipes the tool result through auto_capture.py so
7
7
  decision/correction/explicit facts from tool outputs reach the cognitive
8
8
  layer.
9
9
 
10
+ v6.0.1 adds an inbox-autodetect stage at the end: when the session has
11
+ unread ``nexo_send`` messages AND has gone for ≥60s without a heartbeat,
12
+ the hook emits a ``systemMessage`` telling the agent to run
13
+ ``nexo_heartbeat`` and pick them up. Rate-limited to one reminder per
14
+ minute per SID via the ``hook_inbox_reminders`` table (migration m42).
15
+
10
16
  Failures in one sub-step do not cancel the others. Hook is best-effort;
11
17
  exit code is always 0 so Claude Code never sees a PostToolUse failure.
12
18
  """
@@ -23,6 +29,91 @@ from pathlib import Path
23
29
  _DIR = Path(__file__).resolve().parent
24
30
  _NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
25
31
 
32
+ INBOX_CHECK_THRESHOLD_SECONDS = int(
33
+ os.environ.get("NEXO_INBOX_CHECK_THRESHOLD_SECONDS", "60")
34
+ )
35
+
36
+
37
+ def _resolve_sid_from_payload(payload: dict) -> str:
38
+ """Resolve the NEXO SID from the hook payload or fall back to env.
39
+
40
+ Claude Code delivers its own ``session_id`` in the payload; we map
41
+ it back to our SID via ``sessions.external_session_id``. The
42
+ fallback is ``NEXO_SID`` in the environment, which headless crons
43
+ export directly.
44
+ """
45
+ candidates: list[str] = []
46
+ if isinstance(payload, dict):
47
+ for key in ("nexo_sid", "sid", "session_id", "sessionId"):
48
+ value = payload.get(key)
49
+ if isinstance(value, str) and value.strip():
50
+ candidates.append(value.strip())
51
+ env_sid = os.environ.get("NEXO_SID", "").strip()
52
+ if env_sid:
53
+ candidates.append(env_sid)
54
+ env_claude = os.environ.get("CLAUDE_SESSION_ID", "").strip()
55
+ if env_claude:
56
+ candidates.append(env_claude)
57
+
58
+ # Try each candidate: first as a NEXO-shaped SID (nexo-<epoch>-<pid>),
59
+ # then as a Claude external id we need to translate.
60
+ try:
61
+ sys.path.insert(0, str(_DIR.parent))
62
+ from db import ( # type: ignore
63
+ resolve_sid_from_external,
64
+ get_last_heartbeat_ts,
65
+ )
66
+ except Exception:
67
+ return ""
68
+
69
+ for cand in candidates:
70
+ if cand.startswith("nexo-"):
71
+ return cand
72
+ resolved = resolve_sid_from_external(cand)
73
+ if resolved:
74
+ return resolved
75
+ return ""
76
+
77
+
78
+ def check_inbox_and_emit_reminder(sid: str, now: float | None = None) -> str | None:
79
+ """Return the systemMessage string when a reminder should be surfaced.
80
+
81
+ Returns ``None`` when any gate fails (no sid, no pending messages,
82
+ heartbeat too recent, rate-limited on reminders).
83
+ """
84
+ if not sid:
85
+ return None
86
+ try:
87
+ sys.path.insert(0, str(_DIR.parent))
88
+ from db import ( # type: ignore
89
+ count_pending_inbox_messages,
90
+ get_last_heartbeat_ts,
91
+ get_last_reminder_ts,
92
+ mark_reminder_sent,
93
+ )
94
+ except Exception:
95
+ return None
96
+
97
+ pending = count_pending_inbox_messages(sid)
98
+ if pending <= 0:
99
+ return None
100
+ last_hb = get_last_heartbeat_ts(sid)
101
+ if last_hb is None:
102
+ return None # pre-v6.0.1 row or brand-new session
103
+ current = float(now) if now is not None else time.time()
104
+ if current - last_hb < INBOX_CHECK_THRESHOLD_SECONDS:
105
+ return None
106
+ last_rem = get_last_reminder_ts(sid) or 0.0
107
+ if current - last_rem < INBOX_CHECK_THRESHOLD_SECONDS:
108
+ return None # rate limit: max 1 reminder/min/session
109
+ mark_reminder_sent(sid, current)
110
+ return (
111
+ f"[NEXO Protocol Enforcer] You have {pending} unread inbox message(s) "
112
+ f"sent by other NEXO sessions. Run nexo_heartbeat with your SID now "
113
+ f"to receive them before continuing — other sessions may be blocked "
114
+ f"waiting on your response."
115
+ )
116
+
26
117
 
27
118
  def _record(duration_ms: int, exit_code: int, summary: str) -> None:
28
119
  try:
@@ -116,6 +207,18 @@ def main() -> int:
116
207
 
117
208
  exits.append(_run_auto_capture(payload))
118
209
 
210
+ # v6.0.1 — inbox autodetect runs LAST so it sees the latest DB state
211
+ # (including any writes the previous steps may have done). Emits a
212
+ # single-line JSON systemMessage so Claude Code surfaces it to the
213
+ # agent without breaking the tool pipeline.
214
+ try:
215
+ sid = _resolve_sid_from_payload(payload)
216
+ reminder = check_inbox_and_emit_reminder(sid)
217
+ if reminder:
218
+ print(json.dumps({"systemMessage": reminder}))
219
+ except Exception:
220
+ pass
221
+
119
222
  final_exit = max(exits) if exits else 0
120
223
  duration_ms = int((time.time() - started) * 1000)
121
224
  _record(duration_ms, final_exit, f"steps={len(exits)}")
@@ -2,62 +2,87 @@ from __future__ import annotations
2
2
 
3
3
  """Protocol-discipline settings.
4
4
 
5
- v6.0.0 breaking change: there is no user-facing toggle anymore.
6
-
7
- - Interactive TTY sessions always run ``strict``.
8
- - Non-TTY contexts (crons, tests, pipes) always run ``lenient`` — exactly
9
- what every scheduled background job needs to avoid noisy protocol nags.
10
- - ``VALID_PROTOCOL_STRICTNESS`` still exposes ``learning`` for internal
11
- use by self-audit and onboarding flows, but it is never the active mode
12
- unless the code explicitly asks for it.
13
-
14
- The v5.x surfaces this module used to expose — ``NEXO_PROTOCOL_STRICTNESS``
15
- environment variable, ``preferences.protocol_strictness`` in calibration,
16
- and the ``default/normal/off/warn/soft`` aliases — are all removed on
17
- purpose. Users who relied on them see their value silently cleared by the
18
- v6.0.0 calibration migration and fall through to the TTY/no-TTY decision.
5
+ v6.0.0 removed the user-facing strictness toggle. v6.0.1 layers an
6
+ Electron-class escape hatch on top:
7
+
8
+ - Interactive contexts run ``strict``. Interactive is defined as either
9
+ of two signals:
10
+ * both stdin and stdout are attached to a TTY (terminal users), OR
11
+ * the client set ``NEXO_INTERACTIVE=1`` in the child process env
12
+ (Electron clients like NEXO Desktop spawn ``claude`` through
13
+ pipes, so ``isatty()`` returns False even with a human in the
14
+ loop).
15
+ - Everything else (crons, tests, piped scripts, headless automation)
16
+ runs ``lenient``.
17
+
18
+ ``NEXO_INTERACTIVE`` is a contract between Brain and its interactive
19
+ clients. It is NOT user-facing. It is NOT documented to operators. It
20
+ is NOT the removed ``NEXO_PROTOCOL_STRICTNESS`` knob — that one let a
21
+ user force a strictness value, which confused people. This one only
22
+ signals interactivity; the actual strictness still follows the
23
+ TTY/interactive test above.
24
+
25
+ ``VALID_PROTOCOL_STRICTNESS`` still exposes ``learning`` for internal
26
+ use by self-audit and onboarding flows, but nothing in this module
27
+ ever selects it.
19
28
  """
20
29
 
30
+ import os
21
31
  import sys
22
32
 
23
33
 
24
34
  DEFAULT_PROTOCOL_STRICTNESS = "strict"
25
35
  VALID_PROTOCOL_STRICTNESS = {"lenient", "strict", "learning"}
26
36
 
37
+ # The only accepted value is the exact string "1". Truthy-looking values
38
+ # such as "true", "yes", "on" are deliberately ignored so a typo cannot
39
+ # silently re-enable strict mode on a headless machine.
40
+ _NEXO_INTERACTIVE_OPT_IN = "1"
27
41
 
28
- def _stdio_is_tty() -> bool:
29
- """True only when both stdin and stdout are attached to a terminal.
30
42
 
31
- The double-check matters: a headless cron typically redirects stdout
32
- to a log file but leaves stdin as a TTY. Treating that as interactive
33
- would re-enable strict mode for every cron invocation, which is
34
- exactly the noise v6.0.0 set out to eliminate.
43
+ def _is_interactive() -> bool:
44
+ """True when the process should be treated as interactive.
45
+
46
+ Two signals are accepted (OR semantics):
47
+ 1. stdin and stdout are both TTYs.
48
+ 2. ``NEXO_INTERACTIVE`` is exactly ``"1"`` — the Brain↔Electron
49
+ contract used by NEXO Desktop ≥0.12.0.
50
+ Anything else returns False, falling through to ``lenient``.
35
51
  """
52
+ if os.environ.get("NEXO_INTERACTIVE") == _NEXO_INTERACTIVE_OPT_IN:
53
+ return True
36
54
  try:
37
55
  return bool(sys.stdin.isatty() and sys.stdout.isatty())
38
56
  except Exception:
39
57
  return False
40
58
 
41
59
 
60
+ # Kept as a thin alias for any v6.0.0 caller that imported the old helper
61
+ # directly. New code should prefer ``_is_interactive()``.
62
+ def _stdio_is_tty() -> bool:
63
+ """Deprecated in v6.0.1. Delegates to ``_is_interactive()`` so the
64
+ NEXO_INTERACTIVE contract applies regardless of which name the caller
65
+ imported."""
66
+ return _is_interactive()
67
+
68
+
42
69
  def normalize_protocol_strictness(value: str | None) -> str:
43
70
  """Coerce an arbitrary input into one of the canonical values.
44
71
 
45
- Unknown or empty values return the TTY-derived default. The only
46
- normalization done is lowercasing and whitespace stripping — the v5.x
47
- alias table is gone.
72
+ Unknown or empty values fall through to the interactivity test. The
73
+ only normalisation is lowercasing and whitespace stripping — the
74
+ v5.x alias table (default/normal/off/warn/soft) is gone.
48
75
  """
49
76
  candidate = str(value or "").strip().lower()
50
77
  if candidate in VALID_PROTOCOL_STRICTNESS:
51
78
  return candidate
52
- return "strict" if _stdio_is_tty() else "lenient"
79
+ return "strict" if _is_interactive() else "lenient"
53
80
 
54
81
 
55
82
  def get_protocol_strictness() -> str:
56
83
  """Return the active strictness for this process.
57
84
 
58
- No configuration, no environment, no calibration — only the process
59
- context decides. Callers that want to force a value for tests can
60
- monkeypatch ``sys.stdin.isatty`` / ``sys.stdout.isatty`` or call
61
- ``normalize_protocol_strictness`` directly with an explicit value.
85
+ No configuration, no user-facing environment, no calibration value
86
+ only the process context and the Brain↔client contract decide.
62
87
  """
63
- return "strict" if _stdio_is_tty() else "lenient"
88
+ return "strict" if _is_interactive() else "lenient"
@@ -213,6 +213,21 @@ ALL_REGISTERED_CALLERS: frozenset[str] = frozenset(
213
213
  )
214
214
 
215
215
 
216
+ # v6.0.2 — Reserved caller prefix for user-owned personal scripts that live
217
+ # outside this repo (``~/.nexo/scripts/``). Callers matching this prefix
218
+ # bypass the registry entirely: they cannot be required to register because
219
+ # they ship with each operator's own install. Instead, the script passes
220
+ # either an explicit ``tier`` (semantic) or a ``reasoning_effort`` (direct
221
+ # override) — or falls back to the user's ``default_resonance`` preference,
222
+ # and finally to ``DEFAULT_RESONANCE`` as the last line of defence.
223
+ #
224
+ # The prefix is NOT a loophole for new core scripts. Anything inside the
225
+ # ``src/`` tree or shipped via the core manifest continues to require a
226
+ # registered entry. The docs (``docs/personal-scripts-guide.md``) explain
227
+ # the split to any NEXO session helping an operator author a new script.
228
+ PERSONAL_CALLER_PREFIX = "personal/"
229
+
230
+
216
231
  class UnregisteredCallerError(ValueError):
217
232
  """Raised when a caller string is not in the resonance registry.
218
233
 
@@ -271,22 +286,55 @@ def _load_user_default_resonance() -> str:
271
286
  return ""
272
287
 
273
288
 
274
- def resolve_tier_for_caller(caller: str, user_default: str | None = None) -> str:
275
- """Return the resonance tier that should apply to ``caller``.
289
+ def _normalise_tier(candidate: str | None) -> str:
290
+ """Coerce a tier string to canonical lowercase; empty when invalid."""
291
+ if not candidate:
292
+ return ""
293
+ value = str(candidate).strip().lower()
294
+ return value if value in TIERS else ""
276
295
 
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
296
 
282
- When ``user_default`` is not passed, the function looks it up from the
283
- calibration.json preferences first and schedule.json second.
297
+ def resolve_tier_for_caller(
298
+ caller: str,
299
+ user_default: str | None = None,
300
+ *,
301
+ explicit_tier: str | None = None,
302
+ ) -> str:
303
+ """Return the resonance tier that should apply to ``caller``.
304
+
305
+ Resolution order:
306
+
307
+ 1. ``caller`` is empty → raise ``UnregisteredCallerError`` (same as v6.0.0).
308
+ 2. ``caller`` starts with ``PERSONAL_CALLER_PREFIX``:
309
+ a. ``explicit_tier`` if valid — semantic override from the script.
310
+ b. ``user_default`` if valid — operator's configured default.
311
+ c. Stored ``preferences.default_resonance`` via the loader.
312
+ d. ``DEFAULT_RESONANCE`` as the final fallback.
313
+ The registry is NEVER consulted for personal callers: scripts outside
314
+ the repo cannot register, and forcing them to pin a tier there would
315
+ defeat the whole purpose of the ``personal/`` contract.
316
+ 3. User-facing callers: user default → DEFAULT (unchanged).
317
+ 4. System-owned callers: fixed tier (unchanged).
318
+ 5. Anything else: ``UnregisteredCallerError`` (unchanged).
284
319
  """
285
320
  if not caller:
286
321
  raise UnregisteredCallerError(
287
322
  "caller= is required. Every automation subprocess must be registered "
288
323
  "in src/resonance_map.py so its reasoning budget is deliberate."
289
324
  )
325
+
326
+ if caller.startswith(PERSONAL_CALLER_PREFIX):
327
+ explicit = _normalise_tier(explicit_tier)
328
+ if explicit:
329
+ return explicit
330
+ from_user = _normalise_tier(user_default)
331
+ if from_user:
332
+ return from_user
333
+ from_prefs = _normalise_tier(_load_user_default_resonance())
334
+ if from_prefs:
335
+ return from_prefs
336
+ return DEFAULT_RESONANCE
337
+
290
338
  if caller in USER_FACING_CALLERS:
291
339
  resolved_default = user_default
292
340
  if resolved_default is None:
@@ -308,6 +356,8 @@ def resolve_model_and_effort(
308
356
  caller: str,
309
357
  backend: str,
310
358
  user_default: str | None = None,
359
+ *,
360
+ explicit_tier: str | None = None,
311
361
  ) -> Tuple[str, str]:
312
362
  """Return ``(model, reasoning_effort)`` for ``caller`` on ``backend``.
313
363
 
@@ -316,7 +366,9 @@ def resolve_model_and_effort(
316
366
  empty pair; the caller is expected to handle that by raising or by
317
367
  passing its own explicit model/effort arguments.
318
368
  """
319
- tier = resolve_tier_for_caller(caller, user_default=user_default)
369
+ tier = resolve_tier_for_caller(
370
+ caller, user_default=user_default, explicit_tier=explicit_tier
371
+ )
320
372
  backend_entry = _RESONANCE_TABLE.get(tier, {}).get(backend)
321
373
  if backend_entry is None:
322
374
  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=getattr(args, "caller", "") or "agent_run/generic",
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,
@@ -465,8 +465,15 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
465
465
 
466
466
  def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
467
467
  """Inner body of handle_heartbeat — wrapped by tool_span above."""
468
- from db import get_db
468
+ from db import get_db, update_last_heartbeat_ts
469
469
  update_session(sid, task)
470
+ # v6.0.1 — stamp last_heartbeat_ts so the PostToolUse hook can
471
+ # decide whether to surface a pending-inbox reminder on autopilot
472
+ # sessions. Best-effort: never break the heartbeat on failure.
473
+ try:
474
+ update_last_heartbeat_ts(sid)
475
+ except Exception:
476
+ pass
470
477
 
471
478
  # Temporal anchor — surface authoritative UTC time so clients never drift
472
479
  # on date/day-of-week across long sessions. Neutral ISO-8601, no locale,
@@ -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: