nexo-brain 5.10.2 → 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 +5 -1
- package/bin/nexo-brain.js +281 -183
- package/hooks/hooks.json +18 -48
- package/package.json +3 -1
- package/src/auto_update.py +20 -0
- package/src/calibration_migration.py +134 -0
- 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/hook_observability.py +24 -0
- package/src/hooks/auto_capture.py +312 -91
- package/src/hooks/manifest.json +12 -0
- package/src/hooks/notification.py +78 -0
- package/src/hooks/post_tool_use.py +229 -0
- package/src/hooks/pre_compact.py +50 -0
- package/src/hooks/session_start.py +96 -0
- package/src/hooks/stop.py +50 -0
- package/src/hooks/subagent_stop.py +144 -0
- package/src/protocol_settings.py +67 -38
- package/src/resonance_map.py +69 -25
- package/src/resonance_tiers.json +21 -0
- package/src/tools_sessions.py +8 -1
package/hooks/hooks.json
CHANGED
|
@@ -10,18 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"type": "command",
|
|
13
|
-
"command": "
|
|
14
|
-
"timeout":
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"type": "command",
|
|
18
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/daily-briefing-check.sh\"",
|
|
19
|
-
"timeout": 5
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"type": "command",
|
|
23
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/session-start.sh\"",
|
|
24
|
-
"timeout": 35
|
|
13
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/session_start.py\"",
|
|
14
|
+
"timeout": 40
|
|
25
15
|
}
|
|
26
16
|
]
|
|
27
17
|
}
|
|
@@ -32,87 +22,67 @@
|
|
|
32
22
|
"hooks": [
|
|
33
23
|
{
|
|
34
24
|
"type": "command",
|
|
35
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
36
|
-
"timeout":
|
|
25
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/stop.py\"",
|
|
26
|
+
"timeout": 15
|
|
37
27
|
}
|
|
38
28
|
]
|
|
39
29
|
}
|
|
40
30
|
],
|
|
41
|
-
"
|
|
31
|
+
"UserPromptSubmit": [
|
|
42
32
|
{
|
|
43
|
-
"matcher": "
|
|
33
|
+
"matcher": "*",
|
|
44
34
|
"hooks": [
|
|
45
35
|
{
|
|
46
36
|
"type": "command",
|
|
47
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
37
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/auto_capture.py\"",
|
|
48
38
|
"timeout": 5
|
|
49
39
|
}
|
|
50
40
|
]
|
|
51
41
|
}
|
|
52
42
|
],
|
|
53
|
-
"
|
|
43
|
+
"PostToolUse": [
|
|
54
44
|
{
|
|
55
45
|
"matcher": "*",
|
|
56
46
|
"hooks": [
|
|
57
47
|
{
|
|
58
48
|
"type": "command",
|
|
59
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
60
|
-
"timeout":
|
|
49
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/post_tool_use.py\"",
|
|
50
|
+
"timeout": 20
|
|
61
51
|
}
|
|
62
52
|
]
|
|
63
53
|
}
|
|
64
54
|
],
|
|
65
|
-
"
|
|
55
|
+
"PreCompact": [
|
|
66
56
|
{
|
|
67
57
|
"matcher": "*",
|
|
68
58
|
"hooks": [
|
|
69
59
|
{
|
|
70
60
|
"type": "command",
|
|
71
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
72
|
-
"timeout":
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"type": "command",
|
|
76
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/capture-session.sh\"",
|
|
77
|
-
"timeout": 3
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
"type": "command",
|
|
81
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/inbox-hook.sh\"",
|
|
82
|
-
"timeout": 5
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"type": "command",
|
|
86
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/protocol-guardrail.sh\"",
|
|
87
|
-
"timeout": 5
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"type": "command",
|
|
91
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/heartbeat-posttool.sh\"",
|
|
92
|
-
"timeout": 3
|
|
61
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/pre_compact.py\"",
|
|
62
|
+
"timeout": 15
|
|
93
63
|
}
|
|
94
64
|
]
|
|
95
65
|
}
|
|
96
66
|
],
|
|
97
|
-
"
|
|
67
|
+
"Notification": [
|
|
98
68
|
{
|
|
99
69
|
"matcher": "*",
|
|
100
70
|
"hooks": [
|
|
101
71
|
{
|
|
102
72
|
"type": "command",
|
|
103
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
104
|
-
"timeout":
|
|
73
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/notification.py\"",
|
|
74
|
+
"timeout": 3
|
|
105
75
|
}
|
|
106
76
|
]
|
|
107
77
|
}
|
|
108
78
|
],
|
|
109
|
-
"
|
|
79
|
+
"SubagentStop": [
|
|
110
80
|
{
|
|
111
81
|
"matcher": "*",
|
|
112
82
|
"hooks": [
|
|
113
83
|
{
|
|
114
84
|
"type": "command",
|
|
115
|
-
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\"
|
|
85
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/subagent_stop.py\"",
|
|
116
86
|
"timeout": 10
|
|
117
87
|
}
|
|
118
88
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "
|
|
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",
|
|
@@ -76,6 +76,8 @@
|
|
|
76
76
|
"bin/postinstall.js",
|
|
77
77
|
"scripts/sync_release_artifacts.py",
|
|
78
78
|
"src/",
|
|
79
|
+
"src/resonance_tiers.json",
|
|
80
|
+
"src/hooks/manifest.json",
|
|
79
81
|
"community/",
|
|
80
82
|
"!src/**/__pycache__",
|
|
81
83
|
"!src/**/*.pyc",
|
package/src/auto_update.py
CHANGED
|
@@ -2894,6 +2894,26 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
2894
2894
|
except Exception as exc:
|
|
2895
2895
|
actions.append(f"profile-bootstrap-warning:{exc.__class__.__name__}")
|
|
2896
2896
|
|
|
2897
|
+
# v6.0.0 purge — drop legacy fields that moved elsewhere in v6.
|
|
2898
|
+
# client_runtime_profiles.*.{model,reasoning_effort} → resonance_tiers.json.
|
|
2899
|
+
# preferences.protocol_strictness → TTY/no-TTY detection.
|
|
2900
|
+
# preferences.show_pending_at_start → NEXO Desktop electron-store.
|
|
2901
|
+
# Never re-raises: the update must finish even if purge fails.
|
|
2902
|
+
try:
|
|
2903
|
+
_emit_progress(progress_fn, "Applying v6.0.0 calibration purge...")
|
|
2904
|
+
from calibration_migration import apply_v6_purge
|
|
2905
|
+
v6_result = apply_v6_purge(nexo_home=dest)
|
|
2906
|
+
if v6_result.get("calibration_changed"):
|
|
2907
|
+
actions.append("v6-purge:calibration")
|
|
2908
|
+
if v6_result.get("schedule_changed"):
|
|
2909
|
+
actions.append("v6-purge:schedule")
|
|
2910
|
+
if v6_result.get("seeded_default_resonance"):
|
|
2911
|
+
actions.append("v6-purge:seeded-default-resonance-alto")
|
|
2912
|
+
if v6_result.get("status") == "noop":
|
|
2913
|
+
actions.append("v6-purge:noop")
|
|
2914
|
+
except Exception as exc:
|
|
2915
|
+
actions.append(f"v6-purge-warning:{exc.__class__.__name__}")
|
|
2916
|
+
|
|
2897
2917
|
_emit_progress(progress_fn, "Verifying runtime imports...")
|
|
2898
2918
|
verify = subprocess.run(
|
|
2899
2919
|
[sys.executable, "-c", "import server"],
|
|
@@ -240,3 +240,137 @@ def revert(
|
|
|
240
240
|
except Exception as exc:
|
|
241
241
|
return {"status": "error", "reason": f"copy failed: {exc}", "path": str(target)}
|
|
242
242
|
return {"status": "reverted", "from": str(backup), "path": str(target)}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# v6.0.0 purge
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# v6.0.0 removed three user-facing knobs whose state now lives elsewhere:
|
|
249
|
+
# - ``client_runtime_profiles.{claude_code,codex}.{model,reasoning_effort}``
|
|
250
|
+
# in schedule.json → replaced by resonance_tiers.json (tier is the only
|
|
251
|
+
# input, model+effort are resolved from the JSON).
|
|
252
|
+
# - ``preferences.protocol_strictness`` in calibration.json → replaced by
|
|
253
|
+
# the TTY/no-TTY detection in protocol_settings.py.
|
|
254
|
+
# - ``preferences.show_pending_at_start`` in calibration.json → moved to
|
|
255
|
+
# NEXO Desktop's electron-store; Brain no longer reads or writes it.
|
|
256
|
+
#
|
|
257
|
+
# The update path calls ``apply_v6_purge(nexo_home)`` exactly once per
|
|
258
|
+
# upgrade. It never maps legacy values to new ones (that would re-create
|
|
259
|
+
# learning #398, the reasoning_effort=max → maximo footgun) — legacy
|
|
260
|
+
# fields are dropped silently and callers fall back to the canonical
|
|
261
|
+
# defaults (strict or lenient via TTY, tier=alto).
|
|
262
|
+
|
|
263
|
+
_V6_LEGACY_RUNTIME_KEYS = ("model", "reasoning_effort")
|
|
264
|
+
_V6_LEGACY_PREFS_KEYS = ("protocol_strictness", "show_pending_at_start")
|
|
265
|
+
_V6_DEFAULT_TIER = "alto"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _prune_client_runtime_profiles(schedule: dict) -> bool:
|
|
269
|
+
"""Remove model/reasoning_effort from every client_runtime_profile.
|
|
270
|
+
|
|
271
|
+
Leaves the enclosing dict shape intact so downstream schedule.json
|
|
272
|
+
readers that still iterate ``client_runtime_profiles`` do not need a
|
|
273
|
+
guard — they just see an empty-ish profile for each client.
|
|
274
|
+
"""
|
|
275
|
+
changed = False
|
|
276
|
+
profiles = schedule.get("client_runtime_profiles")
|
|
277
|
+
if not isinstance(profiles, dict):
|
|
278
|
+
return False
|
|
279
|
+
for client, profile in list(profiles.items()):
|
|
280
|
+
if not isinstance(profile, dict):
|
|
281
|
+
continue
|
|
282
|
+
for key in _V6_LEGACY_RUNTIME_KEYS:
|
|
283
|
+
if key in profile:
|
|
284
|
+
profile.pop(key, None)
|
|
285
|
+
changed = True
|
|
286
|
+
return changed
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _prune_calibration_preferences(cal: dict) -> bool:
|
|
290
|
+
"""Drop protocol_strictness and show_pending_at_start from calibration."""
|
|
291
|
+
changed = False
|
|
292
|
+
# Pref dict may be absent or non-dict on pathological payloads.
|
|
293
|
+
prefs = cal.get("preferences")
|
|
294
|
+
if isinstance(prefs, dict):
|
|
295
|
+
for key in _V6_LEGACY_PREFS_KEYS:
|
|
296
|
+
if key in prefs:
|
|
297
|
+
prefs.pop(key, None)
|
|
298
|
+
changed = True
|
|
299
|
+
# Some early v5.x installs wrote protocol_strictness at the top level.
|
|
300
|
+
for key in _V6_LEGACY_PREFS_KEYS:
|
|
301
|
+
if key in cal:
|
|
302
|
+
cal.pop(key, None)
|
|
303
|
+
changed = True
|
|
304
|
+
return changed
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _ensure_default_resonance(cal: dict) -> bool:
|
|
308
|
+
"""Seed preferences.default_resonance='alto' when the user has none.
|
|
309
|
+
|
|
310
|
+
Never overwrites an existing value — respecting a non-default choice
|
|
311
|
+
is the whole point of making this idempotent.
|
|
312
|
+
"""
|
|
313
|
+
prefs = cal.setdefault("preferences", {})
|
|
314
|
+
if not isinstance(prefs, dict):
|
|
315
|
+
# Reset to a sane shape without losing the rest of the payload.
|
|
316
|
+
cal["preferences"] = prefs = {}
|
|
317
|
+
current = str(prefs.get("default_resonance") or "").strip().lower()
|
|
318
|
+
if current:
|
|
319
|
+
return False
|
|
320
|
+
prefs["default_resonance"] = _V6_DEFAULT_TIER
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def apply_v6_purge(
|
|
325
|
+
nexo_home: Path | None = None,
|
|
326
|
+
*,
|
|
327
|
+
dry_run: bool = False,
|
|
328
|
+
) -> dict:
|
|
329
|
+
"""Perform the v6.0.0 migration against an on-disk NEXO_HOME.
|
|
330
|
+
|
|
331
|
+
Returns a dict describing what changed. Never raises — the update
|
|
332
|
+
flow appends the result to the actions trail and keeps going.
|
|
333
|
+
"""
|
|
334
|
+
home = Path(nexo_home) if nexo_home else Path(
|
|
335
|
+
__import__("os").environ.get("NEXO_HOME", str(Path.home() / ".nexo"))
|
|
336
|
+
)
|
|
337
|
+
result: dict[str, Any] = {
|
|
338
|
+
"status": "noop",
|
|
339
|
+
"home": str(home),
|
|
340
|
+
"calibration_changed": False,
|
|
341
|
+
"schedule_changed": False,
|
|
342
|
+
"seeded_default_resonance": False,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
cal_path = home / "brain" / "calibration.json"
|
|
346
|
+
sched_path = home / "config" / "schedule.json"
|
|
347
|
+
|
|
348
|
+
# --- calibration.json ---
|
|
349
|
+
if cal_path.is_file():
|
|
350
|
+
try:
|
|
351
|
+
cal = json.loads(cal_path.read_text())
|
|
352
|
+
except Exception:
|
|
353
|
+
cal = None
|
|
354
|
+
if isinstance(cal, dict):
|
|
355
|
+
pruned = _prune_calibration_preferences(cal)
|
|
356
|
+
seeded = _ensure_default_resonance(cal)
|
|
357
|
+
if (pruned or seeded) and not dry_run:
|
|
358
|
+
cal_path.write_text(json.dumps(cal, ensure_ascii=False, indent=2))
|
|
359
|
+
result["calibration_changed"] = bool(pruned or seeded)
|
|
360
|
+
result["seeded_default_resonance"] = bool(seeded)
|
|
361
|
+
|
|
362
|
+
# --- schedule.json ---
|
|
363
|
+
if sched_path.is_file():
|
|
364
|
+
try:
|
|
365
|
+
sched = json.loads(sched_path.read_text())
|
|
366
|
+
except Exception:
|
|
367
|
+
sched = None
|
|
368
|
+
if isinstance(sched, dict):
|
|
369
|
+
pruned = _prune_client_runtime_profiles(sched)
|
|
370
|
+
if pruned and not dry_run:
|
|
371
|
+
sched_path.write_text(json.dumps(sched, ensure_ascii=False, indent=2))
|
|
372
|
+
result["schedule_changed"] = bool(pruned)
|
|
373
|
+
|
|
374
|
+
if any([result["calibration_changed"], result["schedule_changed"]]):
|
|
375
|
+
result["status"] = "migrated"
|
|
376
|
+
return result
|
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()
|
|
@@ -123,6 +123,30 @@ def record_hook_run(
|
|
|
123
123
|
return 0
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def record_activity(
|
|
127
|
+
*,
|
|
128
|
+
session_id: str = "",
|
|
129
|
+
activity_type: str = "notification",
|
|
130
|
+
metadata: dict | None = None,
|
|
131
|
+
) -> int:
|
|
132
|
+
"""Record a lightweight "session is alive" signal.
|
|
133
|
+
|
|
134
|
+
Consumed by the Notification hook (added in v6.0.0) and by any other
|
|
135
|
+
entry point that wants to tell auto_close_sessions "this session is
|
|
136
|
+
actively doing something, don't prune it". Internally it stores a row
|
|
137
|
+
in hook_runs with hook_name=f'activity:{activity_type}' so the same
|
|
138
|
+
observability surface that renders hook health also shows activity
|
|
139
|
+
pings without needing a second table.
|
|
140
|
+
"""
|
|
141
|
+
hook_tag = f"activity:{(activity_type or 'generic').strip().lower()}"[:120]
|
|
142
|
+
return record_hook_run(
|
|
143
|
+
hook_tag,
|
|
144
|
+
session_id=session_id,
|
|
145
|
+
summary=f"{activity_type or 'generic'} activity",
|
|
146
|
+
metadata=metadata or {"type": activity_type or "generic"},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
126
150
|
def list_recent_hook_runs(
|
|
127
151
|
*,
|
|
128
152
|
hours: int = 24,
|