nexo-brain 5.10.2 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hooks/hooks.json CHANGED
@@ -10,18 +10,8 @@
10
10
  },
11
11
  {
12
12
  "type": "command",
13
- "command": "mkdir -p \"${CLAUDE_PLUGIN_DATA}/operations\" && date +%s > \"${CLAUDE_PLUGIN_DATA}/operations/.session-start-ts\"",
14
- "timeout": 2
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\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/session-stop.sh\"",
36
- "timeout": 10
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
- "PreToolUse": [
31
+ "UserPromptSubmit": [
42
32
  {
43
- "matcher": "Edit|MultiEdit|Write|Delete",
33
+ "matcher": "*",
44
34
  "hooks": [
45
35
  {
46
36
  "type": "command",
47
- "command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/protocol-pretool-guardrail.sh\"",
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
- "UserPromptSubmit": [
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\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/heartbeat-user-msg.sh\"",
60
- "timeout": 3
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
- "PostToolUse": [
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\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/capture-tool-logs.sh\"",
72
- "timeout": 5
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
- "PreCompact": [
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\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/pre-compact.sh\"",
104
- "timeout": 10
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
- "PostCompact": [
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\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/post-compact.sh\"",
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": "5.10.2",
3
+ "version": "6.0.0",
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",
@@ -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
@@ -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,