nexo-brain 6.0.0 → 6.0.1
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 +3 -1
- package/package.json +1 -1
- package/src/db/__init__.py +8 -0
- package/src/db/_hook_inbox_reminders.py +73 -0
- package/src/db/_schema.py +32 -0
- package/src/db/_sessions.py +104 -0
- package/src/hooks/post_tool_use.py +103 -0
- package/src/protocol_settings.py +54 -29
- package/src/tools_sessions.py +8 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
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,9 @@
|
|
|
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.1` is the current packaged-runtime line: 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
|
+
|
|
23
|
+
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
24
|
|
|
23
25
|
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
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
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/db/__init__.py
CHANGED
|
@@ -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
|
|
package/src/db/_sessions.py
CHANGED
|
@@ -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)}")
|
package/src/protocol_settings.py
CHANGED
|
@@ -2,62 +2,87 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
"""Protocol-discipline settings.
|
|
4
4
|
|
|
5
|
-
v6.0.0
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
46
|
-
|
|
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
|
|
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 —
|
|
59
|
-
|
|
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
|
|
88
|
+
return "strict" if _is_interactive() else "lenient"
|
package/src/tools_sessions.py
CHANGED
|
@@ -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,
|