nexo-brain 7.7.0 → 7.8.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/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/src/enforcement_engine.py +90 -0
- package/src/hooks/manifest.json +1 -0
- package/src/hooks/post-compact.sh +90 -20
- package/src/hooks/post_compact.py +66 -0
- package/src/hooks/pre-compact.sh +77 -27
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.0",
|
|
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.
|
|
21
|
+
Version `7.8.0` is the current packaged-runtime line. Minor release that closes 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.
|
|
22
|
+
|
|
23
|
+
Previously in `7.7.0`: minor release that closed the six gaps left partial after v7.6.0's constructor-guardian-90 pass 1 (autonomous detector for `multi_step_task_detected`, R16 vocabulary expansion, R_CATALOG extended to plain Edit/Write, new `R_PRIMITIVE_CHOICE` rule, `R11_plugin_load_pre_inventory` hardened, 12 new contract tests). Post-review hotfix on the same release wired `task_open` rearm properly (discarded from `tools_called` + per-instance pin cleared on `task_close`), added live `on_event` triggers in R14 and R16, and called `on_tool_call_before` before `on_tool_call` in `run_with_enforcement` so before_tool rules fire in Brain the same way Desktop fires `onBeforeToolCall`.
|
|
22
24
|
|
|
23
25
|
Previously in `7.6.0`: minor release that closed the drift between `tool-enforcement-map.json` v2.2 and the two enforcement engines (Brain Python + Desktop JS), added per-instance `after_tool` satisfaction, tightened `learning_add` grace to 0 and `task_open` threshold to 4/must, hardened R15/R17/R22/R_CATALOG from soft to hard, and raised R34 from shadow to soft.
|
|
24
26
|
|
package/hooks/hooks.json
CHANGED
|
@@ -76,6 +76,18 @@
|
|
|
76
76
|
]
|
|
77
77
|
}
|
|
78
78
|
],
|
|
79
|
+
"PostCompact": [
|
|
80
|
+
{
|
|
81
|
+
"matcher": "*",
|
|
82
|
+
"hooks": [
|
|
83
|
+
{
|
|
84
|
+
"type": "command",
|
|
85
|
+
"command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" python3 \"${CLAUDE_PLUGIN_ROOT}/src/hooks/post_compact.py\"",
|
|
86
|
+
"timeout": 15
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
],
|
|
79
91
|
"Notification": [
|
|
80
92
|
{
|
|
81
93
|
"matcher": "*",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.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",
|
|
@@ -2311,6 +2311,96 @@ class HeadlessEnforcer:
|
|
|
2311
2311
|
# v7.6 conditional + deferred on_event reminders.
|
|
2312
2312
|
self._check_conditional()
|
|
2313
2313
|
self._check_on_event_pending()
|
|
2314
|
+
# v7.8 — drain hook-emitted events (pre_compaction, post_compaction).
|
|
2315
|
+
self._consume_pending_hook_events()
|
|
2316
|
+
|
|
2317
|
+
def _consume_pending_hook_events(self):
|
|
2318
|
+
"""v7.8 / v7.8.1 — drain queued hook events for THIS session only.
|
|
2319
|
+
|
|
2320
|
+
pre-compact.sh and post-compact.sh run in separate processes, so
|
|
2321
|
+
they cannot call `raise_event()` directly. They append one NDJSON
|
|
2322
|
+
row per event to `~/.nexo/runtime/data/pending_enforcer_events.ndjson`.
|
|
2323
|
+
|
|
2324
|
+
v7.8.1 (Francisco correction): the queue is GLOBAL across all
|
|
2325
|
+
concurrent sessions, so the engine MUST filter by `self._session_id`
|
|
2326
|
+
before consuming. The original v7.8 drain read every row, fired
|
|
2327
|
+
`raise_event` for all of them, then truncated the whole file —
|
|
2328
|
+
that let a session A enforcer eat events addressed to session B.
|
|
2329
|
+
The fix:
|
|
2330
|
+
|
|
2331
|
+
* Read all rows.
|
|
2332
|
+
* Split into (mine, others) by comparing row["session_id"] to
|
|
2333
|
+
the engine's own `_session_id`.
|
|
2334
|
+
* Fire `raise_event` for MY rows only.
|
|
2335
|
+
* Rewrite the file with only the OTHERS (plus any rows whose
|
|
2336
|
+
session_id we cannot parse — leave them for the next run).
|
|
2337
|
+
|
|
2338
|
+
This preserves the "no double-fire" invariant and also closes
|
|
2339
|
+
the cross-session consumption bug.
|
|
2340
|
+
|
|
2341
|
+
Fail-closed: any parse/IO error is swallowed so a broken queue
|
|
2342
|
+
cannot crash enforcement.
|
|
2343
|
+
"""
|
|
2344
|
+
try:
|
|
2345
|
+
import os
|
|
2346
|
+
nexo_home = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
|
|
2347
|
+
queue_path = os.path.join(nexo_home, "runtime", "data", "pending_enforcer_events.ndjson")
|
|
2348
|
+
if not os.path.isfile(queue_path):
|
|
2349
|
+
return
|
|
2350
|
+
import json
|
|
2351
|
+
try:
|
|
2352
|
+
with open(queue_path, "r", encoding="utf-8") as fh:
|
|
2353
|
+
raw_lines = [ln.rstrip("\n") for ln in fh.readlines()]
|
|
2354
|
+
except Exception:
|
|
2355
|
+
return
|
|
2356
|
+
if not raw_lines:
|
|
2357
|
+
return
|
|
2358
|
+
own_sid = str(self._session_id or "").strip()
|
|
2359
|
+
mine: list[dict] = []
|
|
2360
|
+
keep_raw: list[str] = []
|
|
2361
|
+
for line in raw_lines:
|
|
2362
|
+
s = line.strip()
|
|
2363
|
+
if not s:
|
|
2364
|
+
continue
|
|
2365
|
+
try:
|
|
2366
|
+
row = json.loads(s)
|
|
2367
|
+
except Exception:
|
|
2368
|
+
# Preserve malformed lines so an unrelated parser
|
|
2369
|
+
# error does not silently drop another session's event.
|
|
2370
|
+
keep_raw.append(s)
|
|
2371
|
+
continue
|
|
2372
|
+
row_sid = str((row or {}).get("session_id") or "").strip()
|
|
2373
|
+
if own_sid and row_sid and row_sid == own_sid:
|
|
2374
|
+
mine.append(row)
|
|
2375
|
+
elif not own_sid:
|
|
2376
|
+
# Engine has no session id yet (startup edge). Do
|
|
2377
|
+
# not consume anything — another session might own
|
|
2378
|
+
# these rows.
|
|
2379
|
+
keep_raw.append(s)
|
|
2380
|
+
else:
|
|
2381
|
+
keep_raw.append(s)
|
|
2382
|
+
# Rewrite the file with the rows this session did NOT claim.
|
|
2383
|
+
try:
|
|
2384
|
+
with open(queue_path, "w", encoding="utf-8") as fh:
|
|
2385
|
+
for kept in keep_raw:
|
|
2386
|
+
fh.write(kept + "\n")
|
|
2387
|
+
except Exception:
|
|
2388
|
+
# If the rewrite fails we still have the events cached in
|
|
2389
|
+
# `mine`; we just live with a duplicate-risk for the next
|
|
2390
|
+
# read (still bounded — raise_event is itself idempotent
|
|
2391
|
+
# via its dedup tag).
|
|
2392
|
+
pass
|
|
2393
|
+
for row in mine:
|
|
2394
|
+
event = (row or {}).get("event")
|
|
2395
|
+
if not isinstance(event, str) or not event:
|
|
2396
|
+
continue
|
|
2397
|
+
try:
|
|
2398
|
+
self.raise_event(event, row)
|
|
2399
|
+
except Exception:
|
|
2400
|
+
pass
|
|
2401
|
+
except Exception:
|
|
2402
|
+
# Never crash on consumer errors.
|
|
2403
|
+
pass
|
|
2314
2404
|
|
|
2315
2405
|
def _check_on_event_pending(self):
|
|
2316
2406
|
"""Re-evaluate on_event rules with grace > 0 after message ticks.
|
package/src/hooks/manifest.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
{ "event": "PreToolUse", "handler": "src/hooks/pre_tool_use.py", "critical": true },
|
|
7
7
|
{ "event": "PostToolUse", "handler": "src/hooks/post_tool_use.py", "critical": false },
|
|
8
8
|
{ "event": "PreCompact", "handler": "src/hooks/pre_compact.py", "critical": true },
|
|
9
|
+
{ "event": "PostCompact", "handler": "src/hooks/post_compact.py", "critical": true },
|
|
9
10
|
{ "event": "Stop", "handler": "src/hooks/stop.py", "critical": true },
|
|
10
11
|
{ "event": "Notification", "handler": "src/hooks/notification.py", "critical": false },
|
|
11
12
|
{ "event": "SubagentStop", "handler": "src/hooks/subagent_stop.py", "critical": false }
|
|
@@ -22,22 +22,73 @@ if [ -f "$LOG_FILE" ]; then
|
|
|
22
22
|
LOG_LINES=$(wc -l < "$LOG_FILE" | tr -d ' ')
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
|
-
# Read checkpoint for the session that
|
|
26
|
-
#
|
|
25
|
+
# v7.8 — Read checkpoint for the EXACT session that compacted. Source
|
|
26
|
+
# of truth is the NEXO_HOME-scoped sidecar PreCompact writes; /tmp is
|
|
27
|
+
# gone (multiple conversations racing on /tmp was the root cause of
|
|
28
|
+
# "otra conversación restauró por accidente"). If the exact SID is not
|
|
29
|
+
# available or maps to a different Claude session than this invocation,
|
|
30
|
+
# we FAIL-CLOSED: print a diagnostic Core Memory Block acknowledging
|
|
31
|
+
# the drop and exit without injecting stale context.
|
|
27
32
|
TARGET_SID=""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
# v7.8.1 — per-conversation sidecar. If CLAUDE_SESSION_ID is present
|
|
34
|
+
# we read the token-specific file; otherwise (single-conv legacy path)
|
|
35
|
+
# fall back to the global file. Either way, we rm ONLY the file we
|
|
36
|
+
# actually consumed, never another session's.
|
|
37
|
+
SAFE_CLAUDE_ID=""
|
|
38
|
+
if [ -n "${CLAUDE_SESSION_ID:-}" ]; then
|
|
39
|
+
SAFE_CLAUDE_ID="${CLAUDE_SESSION_ID//[^a-zA-Z0-9._-]/_}"
|
|
40
|
+
COMPACT_STATE="$DATA_DIR/compacting/$SAFE_CLAUDE_ID.txt"
|
|
41
|
+
else
|
|
42
|
+
COMPACT_STATE="$DATA_DIR/compacting-sid.txt"
|
|
43
|
+
fi
|
|
44
|
+
if [ -f "$COMPACT_STATE" ]; then
|
|
45
|
+
RAW_SID=$(cat "$COMPACT_STATE" 2>/dev/null || echo "")
|
|
46
|
+
rm -f "$COMPACT_STATE"
|
|
32
47
|
if [[ "$RAW_SID" =~ ^nexo-[0-9]+-[0-9]+$ ]]; then
|
|
33
48
|
TARGET_SID="$RAW_SID"
|
|
34
49
|
fi
|
|
35
50
|
fi
|
|
36
51
|
|
|
52
|
+
# Cross-check: the SID we recover MUST belong to the Claude session
|
|
53
|
+
# that fired this hook. If the env ships CLAUDE_SESSION_ID, resolve it
|
|
54
|
+
# to a NEXO SID and require a match with the pre-compact sidecar.
|
|
55
|
+
if [ -f "$NEXO_DB" ] && [ -n "${CLAUDE_SESSION_ID:-}" ]; then
|
|
56
|
+
ENV_SID=$(sqlite3 "$NEXO_DB" "
|
|
57
|
+
SELECT sid FROM sessions WHERE claude_session_id = '$CLAUDE_SESSION_ID' LIMIT 1
|
|
58
|
+
" 2>/dev/null || echo "")
|
|
59
|
+
if [ -z "$ENV_SID" ]; then
|
|
60
|
+
ENV_SID=$(sqlite3 "$NEXO_DB" "
|
|
61
|
+
SELECT sid FROM session_claude_aliases WHERE claude_session_id = '$CLAUDE_SESSION_ID' ORDER BY last_seen DESC LIMIT 1
|
|
62
|
+
" 2>/dev/null || echo "")
|
|
63
|
+
fi
|
|
64
|
+
if [ -n "$TARGET_SID" ] && [ -n "$ENV_SID" ] && [ "$TARGET_SID" != "$ENV_SID" ]; then
|
|
65
|
+
# Safer to restore nothing than to restore the wrong conv.
|
|
66
|
+
echo "<!-- NEXO post-compact: SID mismatch (sidecar=$TARGET_SID env=$ENV_SID); skipping rehydration to avoid cross-conv leak. -->"
|
|
67
|
+
# Still emit a post_compaction event so the engine sees the hook ran.
|
|
68
|
+
PENDING_EVENTS="$DATA_DIR/pending_enforcer_events.ndjson"
|
|
69
|
+
python3 -c "
|
|
70
|
+
import json, os, time
|
|
71
|
+
row = {'event': 'post_compaction', 'session_id': os.environ.get('ENV_SID',''),
|
|
72
|
+
'status': 'mismatch', 'sidecar_sid': os.environ.get('TARGET_SID',''),
|
|
73
|
+
'claude_session_id': os.environ.get('CLAUDE_SESSION_ID',''),
|
|
74
|
+
'timestamp': time.time()}
|
|
75
|
+
try:
|
|
76
|
+
with open(os.environ['PENDING_EVENTS'], 'a', encoding='utf-8') as fh:
|
|
77
|
+
fh.write(json.dumps(row, ensure_ascii=False) + '\n')
|
|
78
|
+
except Exception: pass
|
|
79
|
+
" ENV_SID="$ENV_SID" TARGET_SID="$TARGET_SID" CLAUDE_SESSION_ID="${CLAUDE_SESSION_ID:-}" PENDING_EVENTS="$PENDING_EVENTS" >/dev/null 2>&1 || true
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
# If the sidecar was missing, trust the env-resolved SID.
|
|
83
|
+
if [ -z "$TARGET_SID" ] && [ -n "$ENV_SID" ]; then
|
|
84
|
+
TARGET_SID="$ENV_SID"
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
|
|
37
88
|
CHECKPOINT=""
|
|
38
89
|
if [ -f "$NEXO_DB" ]; then
|
|
39
90
|
if [ -n "$TARGET_SID" ]; then
|
|
40
|
-
# Read checkpoint for the specific session that compacted
|
|
91
|
+
# Read checkpoint for the specific session that compacted.
|
|
41
92
|
CHECKPOINT=$(sqlite3 "$NEXO_DB" "
|
|
42
93
|
SELECT sid, task, task_status, active_files, current_goal,
|
|
43
94
|
decisions_summary, errors_found, reasoning_thread,
|
|
@@ -46,16 +97,10 @@ if [ -f "$NEXO_DB" ]; then
|
|
|
46
97
|
WHERE sid = '$TARGET_SID'
|
|
47
98
|
" 2>/dev/null || echo "")
|
|
48
99
|
fi
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
decisions_summary, errors_found, reasoning_thread,
|
|
54
|
-
next_step, compaction_count
|
|
55
|
-
FROM session_checkpoints
|
|
56
|
-
ORDER BY updated_at DESC LIMIT 1
|
|
57
|
-
" 2>/dev/null || echo "")
|
|
58
|
-
fi
|
|
100
|
+
# v7.8: NO MORE latest-checkpoint fallback. Francisco flagged this
|
|
101
|
+
# explicitly — restoring the wrong conversation is worse than
|
|
102
|
+
# restoring nothing. We leave CHECKPOINT empty so the "Core memory
|
|
103
|
+
# block" prints a small diagnostic and exits cleanly.
|
|
59
104
|
|
|
60
105
|
if [ -n "$CHECKPOINT" ]; then
|
|
61
106
|
# Parse pipe-separated fields
|
|
@@ -178,10 +223,13 @@ print('**Guardrail:** skip option menus, reprioritization, summaries, and audits
|
|
|
178
223
|
}
|
|
179
224
|
HOOKEOF
|
|
180
225
|
else
|
|
181
|
-
#
|
|
182
|
-
|
|
226
|
+
# v7.8 fail-closed: no checkpoint for the exact SID. We do NOT
|
|
227
|
+
# inject another conversation's context (that was the pre-v7.8
|
|
228
|
+
# bug). Minimal diagnostic so the operator can call
|
|
229
|
+
# nexo_heartbeat with the right SID if they want to continue.
|
|
230
|
+
cat << HOOKEOF
|
|
183
231
|
{
|
|
184
|
-
"systemMessage": "Post-compaction: no
|
|
232
|
+
"systemMessage": "Post-compaction (SID=${TARGET_SID:-unknown}): no checkpoint for this exact session. Call nexo_heartbeat(sid='${TARGET_SID:-<run nexo_startup>}') to rehydrate — NEXO did NOT restore a different conversation's checkpoint to avoid cross-conv leaks."
|
|
185
233
|
}
|
|
186
234
|
HOOKEOF
|
|
187
235
|
fi
|
|
@@ -192,3 +240,25 @@ else
|
|
|
192
240
|
}
|
|
193
241
|
HOOKEOF
|
|
194
242
|
fi
|
|
243
|
+
|
|
244
|
+
# v7.8 — emit post_compaction event for the engine consumer.
|
|
245
|
+
PENDING_EVENTS="$DATA_DIR/pending_enforcer_events.ndjson"
|
|
246
|
+
python3 -c "
|
|
247
|
+
import json, os, sys, time
|
|
248
|
+
target = os.environ.get('TARGET_SID', '')
|
|
249
|
+
pending = os.environ.get('PENDING_EVENTS', '')
|
|
250
|
+
if not pending:
|
|
251
|
+
sys.exit(0)
|
|
252
|
+
row = {
|
|
253
|
+
'event': 'post_compaction',
|
|
254
|
+
'session_id': target,
|
|
255
|
+
'claude_session_id': os.environ.get('CLAUDE_SESSION_ID', ''),
|
|
256
|
+
'status': 'restored' if target else 'no_target',
|
|
257
|
+
'timestamp': time.time(),
|
|
258
|
+
}
|
|
259
|
+
try:
|
|
260
|
+
with open(pending, 'a', encoding='utf-8') as fh:
|
|
261
|
+
fh.write(json.dumps(row, ensure_ascii=False) + '\n')
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
" TARGET_SID="$TARGET_SID" CLAUDE_SESSION_ID="${CLAUDE_SESSION_ID:-}" PENDING_EVENTS="$PENDING_EVENTS" >/dev/null 2>&1 || true
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostCompact unified handler — delegates to post-compact.sh.
|
|
3
|
+
|
|
4
|
+
The real work (checkpoint lookup, fail-closed cross-conv guard, Core
|
|
5
|
+
Memory Block systemMessage emission, pending-event enqueue) lives in
|
|
6
|
+
the shell script. This wrapper runs it, captures its stdout verbatim
|
|
7
|
+
(so Claude Code gets the systemMessage JSON), and records an entry in
|
|
8
|
+
hook_runs for auditability.
|
|
9
|
+
|
|
10
|
+
Matches pre_compact.py shape — one .py handler per event so the
|
|
11
|
+
manifest can keep a single clean row per hook type.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_DIR = Path(__file__).resolve().parent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _record(duration_ms: int, exit_code: int, session_id: str) -> None:
|
|
26
|
+
try:
|
|
27
|
+
sys.path.insert(0, str(_DIR.parent))
|
|
28
|
+
import hook_observability # type: ignore
|
|
29
|
+
hook_observability.record_hook_run(
|
|
30
|
+
"post_compact",
|
|
31
|
+
duration_ms=duration_ms,
|
|
32
|
+
exit_code=exit_code,
|
|
33
|
+
session_id=session_id,
|
|
34
|
+
)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
started = time.time()
|
|
41
|
+
script = _DIR / "post-compact.sh"
|
|
42
|
+
exit_code = 0
|
|
43
|
+
# Preserve stdout: Claude Code reads the JSON systemMessage line
|
|
44
|
+
# the shell script prints. We proxy it through so the runtime sees
|
|
45
|
+
# exactly what post-compact.sh emits.
|
|
46
|
+
if script.is_file():
|
|
47
|
+
try:
|
|
48
|
+
r = subprocess.run(
|
|
49
|
+
["bash", str(script)], timeout=15, capture_output=True
|
|
50
|
+
)
|
|
51
|
+
if r.stdout:
|
|
52
|
+
sys.stdout.write(r.stdout.decode("utf-8", errors="replace"))
|
|
53
|
+
sys.stdout.flush()
|
|
54
|
+
exit_code = r.returncode
|
|
55
|
+
except Exception:
|
|
56
|
+
exit_code = 1
|
|
57
|
+
_record(
|
|
58
|
+
int((time.time() - started) * 1000),
|
|
59
|
+
exit_code,
|
|
60
|
+
os.environ.get("CLAUDE_SESSION_ID", ""),
|
|
61
|
+
)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
sys.exit(main())
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -24,34 +24,62 @@ if [ -f "$LOG_FILE" ]; then
|
|
|
24
24
|
LOG_LINES=$(wc -l < "$LOG_FILE" | tr -d ' ')
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
# v7.8 — Exact session targeting. Claude Code passes the external
|
|
28
|
+
# session token through CLAUDE_SESSION_ID to every hook. We resolve
|
|
29
|
+
# THAT token to the NEXO SID via session_claude_aliases (or direct
|
|
30
|
+
# match on sessions.claude_session_id). LATEST_SID fallback is gone —
|
|
31
|
+
# multi-conversation Desktop made it actively wrong, because the
|
|
32
|
+
# "latest active" session frequently belongs to a different conv than
|
|
33
|
+
# the one Claude Code is compacting in this hook invocation.
|
|
34
|
+
TARGET_SID=""
|
|
35
|
+
if [ -f "$NEXO_DB" ] && [ -n "${CLAUDE_SESSION_ID:-}" ]; then
|
|
36
|
+
TARGET_SID=$(sqlite3 "$NEXO_DB" "
|
|
37
|
+
SELECT sid FROM sessions WHERE claude_session_id = '$CLAUDE_SESSION_ID' LIMIT 1
|
|
32
38
|
" 2>/dev/null || echo "")
|
|
39
|
+
if [ -z "$TARGET_SID" ]; then
|
|
40
|
+
TARGET_SID=$(sqlite3 "$NEXO_DB" "
|
|
41
|
+
SELECT sid FROM session_claude_aliases WHERE claude_session_id = '$CLAUDE_SESSION_ID' ORDER BY last_seen DESC LIMIT 1
|
|
42
|
+
" 2>/dev/null || echo "")
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
if [ -f "$NEXO_DB" ] && [ -n "$TARGET_SID" ] && [[ "$TARGET_SID" =~ ^nexo-[0-9]+-[0-9]+$ ]]; then
|
|
47
|
+
# v7.8.1 (Francisco correction): the sidecar is per-conversation now.
|
|
48
|
+
# A single global `compacting-sid.txt` let two near-concurrent
|
|
49
|
+
# compactions on two different conversations clobber each other's
|
|
50
|
+
# state. We key the sidecar by the Claude session token that fired
|
|
51
|
+
# this hook (unique per conversation in Desktop). If the env token
|
|
52
|
+
# is unset we keep writing the legacy global file so single-conv
|
|
53
|
+
# callers are unaffected.
|
|
54
|
+
mkdir -p "$DATA_DIR/compacting" 2>/dev/null || true
|
|
55
|
+
if [ -n "${CLAUDE_SESSION_ID:-}" ]; then
|
|
56
|
+
# Sanitise to a filesystem-safe token without paying a subshell
|
|
57
|
+
# per hook invocation.
|
|
58
|
+
SAFE_CLAUDE_ID="${CLAUDE_SESSION_ID//[^a-zA-Z0-9._-]/_}"
|
|
59
|
+
COMPACT_STATE="$DATA_DIR/compacting/$SAFE_CLAUDE_ID.txt"
|
|
60
|
+
else
|
|
61
|
+
COMPACT_STATE="$DATA_DIR/compacting-sid.txt"
|
|
62
|
+
fi
|
|
63
|
+
printf '%s\n' "$TARGET_SID" > "$COMPACT_STATE"
|
|
64
|
+
|
|
65
|
+
# Pull diary draft data into checkpoint for the EXACT session.
|
|
66
|
+
sqlite3 "$NEXO_DB" "
|
|
67
|
+
INSERT INTO session_checkpoints (sid, task, current_goal, updated_at)
|
|
68
|
+
SELECT s.sid, s.task, COALESCE(d.last_context_hint, s.task), datetime('now')
|
|
69
|
+
FROM sessions s
|
|
70
|
+
LEFT JOIN session_diary_draft d ON d.sid = s.sid
|
|
71
|
+
WHERE s.sid = '$TARGET_SID'
|
|
72
|
+
ON CONFLICT(sid) DO UPDATE SET
|
|
73
|
+
task = excluded.task,
|
|
74
|
+
current_goal = CASE
|
|
75
|
+
WHEN excluded.current_goal != '' THEN excluded.current_goal
|
|
76
|
+
ELSE session_checkpoints.current_goal
|
|
77
|
+
END,
|
|
78
|
+
updated_at = datetime('now')
|
|
79
|
+
" 2>/dev/null || true
|
|
80
|
+
|
|
81
|
+
# Flush the richer durable checkpoint state if milestone data exists.
|
|
82
|
+
NEXO_PRECOMPACT_SID="$TARGET_SID" HOOK_DIR="$HOOK_DIR" python3 -c "
|
|
55
83
|
import os, sys
|
|
56
84
|
sys.path.insert(0, os.path.abspath(os.path.join(os.environ['HOOK_DIR'], '..')))
|
|
57
85
|
try:
|
|
@@ -63,7 +91,29 @@ try:
|
|
|
63
91
|
except Exception:
|
|
64
92
|
pass
|
|
65
93
|
" 2>/dev/null || true
|
|
66
|
-
|
|
94
|
+
|
|
95
|
+
# v7.8 — enqueue a `pre_compaction` event for the live engine to
|
|
96
|
+
# consume on the next tool call, so the map's on_event rule fires
|
|
97
|
+
# from the real stream instead of only via tests.
|
|
98
|
+
PENDING_EVENTS="$DATA_DIR/pending_enforcer_events.ndjson"
|
|
99
|
+
python3 -c "
|
|
100
|
+
import json, os, sys, time
|
|
101
|
+
target = os.environ.get('NEXO_PRECOMPACT_SID', '')
|
|
102
|
+
pending = os.environ.get('PENDING_EVENTS', '')
|
|
103
|
+
if not (target and pending):
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
row = {
|
|
106
|
+
'event': 'pre_compaction',
|
|
107
|
+
'session_id': target,
|
|
108
|
+
'claude_session_id': os.environ.get('CLAUDE_SESSION_ID', ''),
|
|
109
|
+
'timestamp': time.time(),
|
|
110
|
+
}
|
|
111
|
+
try:
|
|
112
|
+
with open(pending, 'a', encoding='utf-8') as fh:
|
|
113
|
+
fh.write(json.dumps(row, ensure_ascii=False) + '\n')
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
" NEXO_PRECOMPACT_SID="$TARGET_SID" PENDING_EVENTS="$PENDING_EVENTS" CLAUDE_SESSION_ID="${CLAUDE_SESSION_ID:-}" >/dev/null 2>&1 || true
|
|
67
117
|
fi
|
|
68
118
|
|
|
69
119
|
# ── Layer 2: Emergency auto-diary before compaction ──────────────────
|