nexo-brain 7.8.1 → 7.8.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": "7.8.
|
|
3
|
+
"version": "7.8.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,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 `7.8.
|
|
21
|
+
Version `7.8.2` is the current packaged-runtime line. Patch release that fixes the compact-hook observability gap Francisco flagged after v7.8.1: `hook_runs.session_id` was empty for 7 out of 8 recent compaction rows (and when populated it stored the raw Claude Code token instead of the NEXO sid), so per-session queries over `hook_runs` for compact events could not be joined back to the NEXO session that actually compacted. v7.8.2 adds `src/hooks/compact_session_resolver.py` with `resolve_nexo_sid(claude_session_id)`, which walks the same rails the shell already uses: `sessions.claude_session_id` match, then `session_claude_aliases.claude_session_id` (most recent `last_seen` wins), then the per-conversation sidecar under `runtime/data/compacting/<safe-claude-id>.txt`, then the legacy global sidecar for single-conversation setups. `src/hooks/pre_compact.py` and `src/hooks/post_compact.py` now call the resolver and store the real NEXO sid in `hook_runs.session_id`; both wrappers also stash `{claude_session_id, sid_source}` in `hook_runs.metadata` so "why is this row still empty?" has a one-query answer. Nine new tests in `tests/test_hook_runs_compact_sid_resolution.py` pin the five resolver rails (sessions / alias / sidecar / legacy / none), malformed-sidecar rejection, the pre- and post-compact wrapper end-to-end paths, and the empty-state wrapper rail so a clean audit trail is written even when nothing resolves. No Desktop bump.
|
|
22
|
+
|
|
23
|
+
Previously in `7.8.1`: patch release that closed the last compaction-continuity gap Francisco flagged after v7.8.0: `pre-compact.sh` Layer 2 emergency auto-diary and Layer 3 `compaction_memory.record_auto_flush` now use the exact `TARGET_SID` resolved from `CLAUDE_SESSION_ID` instead of falling back to `ORDER BY last_update_epoch DESC LIMIT 1` ("latest active session"). In multi-conversation Desktop that fallback routinely wrote the emergency diary against the wrong conversation even though the main restore path was already exact-SID in v7.8.0. `last_diary_ts` is also scoped by `session_id` now. Fail-closed when no `CLAUDE_SESSION_ID` resolves. New behavioural tests drive the real shell script with two sessions in the DB to pin the invariant. Fixed a latent bash-escape bug in `pre-compact.sh` where a double-quoted string inside a Python comment silently closed the `python3 -c "..."` argument early — caught by adding the behavioural tests. Pytest 2092 passing (+2 new behavioural). No Desktop bump.
|
|
22
24
|
|
|
23
25
|
Previously in `7.8.0`: minor release that closed the PostCompact continuity work Francisco requested after v7.7: `src/hooks/post_compact.py` is a real registered hook (part of the canonical 9-hook set, was 8), `pre-compact.sh` resolves the exact NEXO SID from `CLAUDE_SESSION_ID` instead of falling back to "latest active session" (that was actively wrong in multi-conversation Desktop), the sidecar moves from `/tmp` to `$NEXO_HOME/runtime/data/compacting-sid.txt` so two concurrent compactions on two conversations cannot race on `/tmp`, `post-compact.sh` removes its "latest checkpoint" fallback (fail-closed to a diagnostic systemMessage instead of restoring the wrong conversation), and the hook cross-checks the sidecar SID against the env-resolved one so a "SID mismatch" is logged as such. Pre- and post-compact now emit NDJSON events the engine drains on every periodic tick via `_consume_pending_hook_events()`; the queue file is truncated after read so an event never fires twice. A new contract test (`tests/test_v78_compaction_continuity.py`) pins 11 invariants across ten rails including the hook registration, the exact-SID resolution path, fail-closed behaviour, and that `compaction_count` only increments on real restore. Pytest 2086 passing (+16 vs v7.7). No Desktop bump — v0.27.0 continues to ship.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.8.
|
|
3
|
+
"version": "7.8.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",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Resolve the NEXO sid for compaction hook observability.
|
|
2
|
+
|
|
3
|
+
`hook_runs.session_id` must hold the NEXO sid (`nexo-NNNNNN-N`) so that a
|
|
4
|
+
query like "every compaction of session X" works without joining on the
|
|
5
|
+
raw Claude Code token. Pre-v7.8.2 the two Python wrappers stored
|
|
6
|
+
`os.environ.get("CLAUDE_SESSION_ID", "")` directly, which produced two
|
|
7
|
+
problems at once: rows with `session_id=''` when the env was missing,
|
|
8
|
+
and rows with the raw Claude token (not a NEXO sid) when it was
|
|
9
|
+
present. This helper centralises the resolution against the same rails
|
|
10
|
+
the shell scripts use.
|
|
11
|
+
|
|
12
|
+
Resolution order:
|
|
13
|
+
1. ENV `CLAUDE_SESSION_ID` with `sessions.claude_session_id` match.
|
|
14
|
+
2. ENV `CLAUDE_SESSION_ID` with `session_claude_aliases.claude_session_id`
|
|
15
|
+
match (most recent `last_seen` wins).
|
|
16
|
+
3. Per-conversation sidecar written by pre-compact.sh at
|
|
17
|
+
the compacting folder under the runtime data dir.
|
|
18
|
+
4. Legacy global sidecar at compacting-sid.txt (single-conv legacy path).
|
|
19
|
+
|
|
20
|
+
Returns (nexo_sid, source) so the caller can stash `source` in the
|
|
21
|
+
`hook_runs.metadata` JSON for debugging "why is this row still empty".
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sqlite3
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
_NEXO_SID_RE = re.compile(r"^nexo-[0-9]+-[0-9]+$")
|
|
31
|
+
_SAFE_CLAUDE_ID_RE = re.compile(r"[^a-zA-Z0-9._-]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _nexo_home() -> Path:
|
|
35
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _candidate_data_dirs() -> list[Path]:
|
|
39
|
+
home = _nexo_home()
|
|
40
|
+
dirs: list[Path] = []
|
|
41
|
+
for cand in (home / "runtime" / "data", home / "data"):
|
|
42
|
+
if cand not in dirs:
|
|
43
|
+
dirs.append(cand)
|
|
44
|
+
return dirs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _db_path() -> Path | None:
|
|
48
|
+
for d in _candidate_data_dirs():
|
|
49
|
+
p = d / "nexo.db"
|
|
50
|
+
if p.is_file():
|
|
51
|
+
return p
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_claude_id(claude_session_id: str) -> str:
|
|
56
|
+
return _SAFE_CLAUDE_ID_RE.sub("_", claude_session_id or "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_sidecar(path: Path) -> str:
|
|
60
|
+
try:
|
|
61
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
62
|
+
except Exception:
|
|
63
|
+
return ""
|
|
64
|
+
return text if _NEXO_SID_RE.match(text) else ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _db_lookup(claude_session_id: str) -> tuple[str, str]:
|
|
68
|
+
if not claude_session_id:
|
|
69
|
+
return "", ""
|
|
70
|
+
db = _db_path()
|
|
71
|
+
if db is None:
|
|
72
|
+
return "", ""
|
|
73
|
+
try:
|
|
74
|
+
conn = sqlite3.connect(str(db), timeout=3)
|
|
75
|
+
except Exception:
|
|
76
|
+
return "", ""
|
|
77
|
+
try:
|
|
78
|
+
try:
|
|
79
|
+
row = conn.execute(
|
|
80
|
+
"SELECT sid FROM sessions WHERE claude_session_id = ? LIMIT 1",
|
|
81
|
+
(claude_session_id,),
|
|
82
|
+
).fetchone()
|
|
83
|
+
except Exception:
|
|
84
|
+
row = None
|
|
85
|
+
if row and row[0] and _NEXO_SID_RE.match(row[0]):
|
|
86
|
+
return row[0], "sessions"
|
|
87
|
+
try:
|
|
88
|
+
row = conn.execute(
|
|
89
|
+
"SELECT sid FROM session_claude_aliases "
|
|
90
|
+
"WHERE claude_session_id = ? "
|
|
91
|
+
"ORDER BY last_seen DESC LIMIT 1",
|
|
92
|
+
(claude_session_id,),
|
|
93
|
+
).fetchone()
|
|
94
|
+
except Exception:
|
|
95
|
+
row = None
|
|
96
|
+
if row and row[0] and _NEXO_SID_RE.match(row[0]):
|
|
97
|
+
return row[0], "alias"
|
|
98
|
+
finally:
|
|
99
|
+
try:
|
|
100
|
+
conn.close()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return "", ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_nexo_sid(claude_session_id: str = "") -> tuple[str, str]:
|
|
107
|
+
"""Resolve the NEXO sid for the current compaction invocation.
|
|
108
|
+
|
|
109
|
+
Returns ``(nexo_sid, source)`` where ``source`` is one of:
|
|
110
|
+
|
|
111
|
+
- ``sessions`` resolved via sessions table claude_session_id.
|
|
112
|
+
- ``alias`` resolved via session_claude_aliases.
|
|
113
|
+
- ``sidecar`` per-conversation sidecar file.
|
|
114
|
+
- ``sidecar_legacy`` legacy global sidecar (single-conv path).
|
|
115
|
+
- ``none`` no rail matched; caller stores empty string.
|
|
116
|
+
"""
|
|
117
|
+
token = (claude_session_id or os.environ.get("CLAUDE_SESSION_ID", "") or "").strip()
|
|
118
|
+
|
|
119
|
+
if token:
|
|
120
|
+
sid, source = _db_lookup(token)
|
|
121
|
+
if sid:
|
|
122
|
+
return sid, source
|
|
123
|
+
safe_id = _safe_claude_id(token)
|
|
124
|
+
if safe_id:
|
|
125
|
+
for base in _candidate_data_dirs():
|
|
126
|
+
side = _read_sidecar(base / "compacting" / f"{safe_id}.txt")
|
|
127
|
+
if side:
|
|
128
|
+
return side, "sidecar"
|
|
129
|
+
|
|
130
|
+
for base in _candidate_data_dirs():
|
|
131
|
+
side = _read_sidecar(base / "compacting-sid.txt")
|
|
132
|
+
if side:
|
|
133
|
+
return side, "sidecar_legacy"
|
|
134
|
+
|
|
135
|
+
return "", "none"
|
|
@@ -22,15 +22,30 @@ from pathlib import Path
|
|
|
22
22
|
_DIR = Path(__file__).resolve().parent
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def _record(duration_ms: int, exit_code: int,
|
|
25
|
+
def _record(duration_ms: int, exit_code: int, claude_session_id: str) -> None:
|
|
26
|
+
"""Log a hook_runs row with the resolved NEXO sid.
|
|
27
|
+
|
|
28
|
+
v7.8.2 — see the matching docstring in pre_compact.py. Post-compact
|
|
29
|
+
runs after the shell script has already consumed the per-conv
|
|
30
|
+
sidecar, but the DB rails (sessions/aliases) stay valid, so the
|
|
31
|
+
resolver still returns a sid in the common case. `sid_source` goes
|
|
32
|
+
into metadata for empty-row triage.
|
|
33
|
+
"""
|
|
26
34
|
try:
|
|
27
35
|
sys.path.insert(0, str(_DIR.parent))
|
|
36
|
+
sys.path.insert(0, str(_DIR))
|
|
28
37
|
import hook_observability # type: ignore
|
|
38
|
+
from compact_session_resolver import resolve_nexo_sid # type: ignore
|
|
39
|
+
nexo_sid, sid_source = resolve_nexo_sid(claude_session_id)
|
|
29
40
|
hook_observability.record_hook_run(
|
|
30
41
|
"post_compact",
|
|
31
42
|
duration_ms=duration_ms,
|
|
32
43
|
exit_code=exit_code,
|
|
33
|
-
session_id=
|
|
44
|
+
session_id=nexo_sid,
|
|
45
|
+
metadata={
|
|
46
|
+
"claude_session_id": claude_session_id,
|
|
47
|
+
"sid_source": sid_source,
|
|
48
|
+
},
|
|
34
49
|
)
|
|
35
50
|
except Exception:
|
|
36
51
|
pass
|
package/src/hooks/pre_compact.py
CHANGED
|
@@ -18,14 +18,33 @@ _DIR = Path(__file__).resolve().parent
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _record(duration_ms: int, exit_code: int) -> None:
|
|
21
|
+
"""Log a hook_runs row with the resolved NEXO sid.
|
|
22
|
+
|
|
23
|
+
v7.8.2 — the raw `CLAUDE_SESSION_ID` env token is not a NEXO sid, so
|
|
24
|
+
storing it in `hook_runs.session_id` made per-session queries useless
|
|
25
|
+
and left the column empty whenever Claude Code did not forward the
|
|
26
|
+
env. `compact_session_resolver.resolve_nexo_sid` walks the same
|
|
27
|
+
rails the shell script uses (sessions → aliases → per-conv sidecar
|
|
28
|
+
→ legacy global sidecar) and returns `(nexo_sid, source)`. The raw
|
|
29
|
+
Claude token and the resolution source end up in `metadata` so an
|
|
30
|
+
operator can debug why a given row is still empty.
|
|
31
|
+
"""
|
|
21
32
|
try:
|
|
22
33
|
sys.path.insert(0, str(_DIR.parent))
|
|
34
|
+
sys.path.insert(0, str(_DIR))
|
|
23
35
|
import hook_observability # type: ignore
|
|
36
|
+
from compact_session_resolver import resolve_nexo_sid # type: ignore
|
|
37
|
+
claude_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
38
|
+
nexo_sid, sid_source = resolve_nexo_sid(claude_id)
|
|
24
39
|
hook_observability.record_hook_run(
|
|
25
40
|
"pre_compact",
|
|
26
41
|
duration_ms=duration_ms,
|
|
27
42
|
exit_code=exit_code,
|
|
28
|
-
session_id=
|
|
43
|
+
session_id=nexo_sid,
|
|
44
|
+
metadata={
|
|
45
|
+
"claude_session_id": claude_id,
|
|
46
|
+
"sid_source": sid_source,
|
|
47
|
+
},
|
|
29
48
|
)
|
|
30
49
|
except Exception:
|
|
31
50
|
pass
|