nexo-brain 7.7.0 → 7.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.7.0",
3
+ "version": "7.8.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,11 @@
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.7.0` is the current packaged-runtime line. Minor release that closes the six gaps left partial after v7.6.0's constructor-guardian-90 pass 1. Gap 1: an autonomous detector now raises `multi_step_task_detected` after three recent Edit/Write/Task calls without a prior `nexo_skill_match` (v7.6 dispatched the event but nothing raised it). Gap 2: the R16 classifier prompt recognises the full done-claim vocabulary (sent / delivered / published / deployed / released / fixed / resolved / merged / pushed plus Spanish equivalents) so the on_event trigger `done_claimed_with_open_task` fires beyond the word "done". Gap 3: R_CATALOG extends to plain `Edit` / `Write` into artefact-bearing paths (skills/, plugins/, personal scripts, `templates/core-prompts/`) and grows its discovery set to include `nexo_personal_scripts_list` + `nexo_plugin_list`. Gap 4: new `R_PRIMITIVE_CHOICE` runtime rule gates Edit/Write of a brand-new artefact without a recent primitive-choice probe (SK-CREATE-NEXO-PRIMITIVE). Gap 5: `guardian_default.json` v1.5.0 raises `R11_plugin_load_pre_inventory` from soft to hard. Gap 6: new `tests/test_v77_enforcement_gaps.py` pins 12 invariants across all six rails. Pytest 2070 passing (+14 vs v7.6.0). Companion release: NEXO Desktop v0.27.0 mirrors the guardian default bumps.
21
+ Version `7.8.1` is the current packaged-runtime line. Patch release that closes 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
+
23
+ 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
+
25
+ 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
26
 
23
27
  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
28
 
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.7.0",
3
+ "version": "7.8.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",
@@ -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.
@@ -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 just compacted
26
- # PreCompact writes the SID to /tmp/nexo-compacting-sid
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
- if [ -f /tmp/nexo-compacting-sid ]; then
29
- RAW_SID=$(cat /tmp/nexo-compacting-sid 2>/dev/null || echo "")
30
- rm -f /tmp/nexo-compacting-sid
31
- # Validate SID format: must be nexo-DIGITS-DIGITS
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
- # Fallback: if no target SID or no checkpoint found, use latest
50
- if [ -z "$CHECKPOINT" ]; then
51
- CHECKPOINT=$(sqlite3 "$NEXO_DB" "
52
- SELECT sid, task, task_status, active_files, current_goal,
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
- # No checkpoint fallback to basic message
182
- cat << 'HOOKEOF'
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 prior checkpoint found. Call nexo_heartbeat to reconnect session state."
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())
@@ -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
- # Enrich checkpoint: copy diary draft context into checkpoint if exists
28
- if [ -f "$NEXO_DB" ]; then
29
- # Get latest active session's diary draft
30
- LATEST_SID=$(sqlite3 "$NEXO_DB" "
31
- SELECT sid FROM sessions ORDER BY last_update_epoch DESC LIMIT 1
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
45
+
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
33
80
 
34
- if [ -n "$LATEST_SID" ] && [[ "$LATEST_SID" =~ ^nexo-[0-9]+-[0-9]+$ ]]; then
35
- # Write SID to temp file so PostCompact knows which session compacted
36
- echo "$LATEST_SID" > /tmp/nexo-compacting-sid
37
- # Pull diary draft data into checkpoint
38
- sqlite3 "$NEXO_DB" "
39
- INSERT INTO session_checkpoints (sid, task, current_goal, updated_at)
40
- SELECT s.sid, s.task, COALESCE(d.last_context_hint, s.task), datetime('now')
41
- FROM sessions s
42
- LEFT JOIN session_diary_draft d ON d.sid = s.sid
43
- WHERE s.sid = '$LATEST_SID'
44
- ON CONFLICT(sid) DO UPDATE SET
45
- task = excluded.task,
46
- current_goal = CASE
47
- WHEN excluded.current_goal != '' THEN excluded.current_goal
48
- ELSE session_checkpoints.current_goal
49
- END,
50
- updated_at = datetime('now')
51
- " 2>/dev/null || true
52
-
53
- # Flush the richer durable checkpoint state if milestone data exists.
54
- NEXO_PRECOMPACT_SID="$LATEST_SID" HOOK_DIR="$HOOK_DIR" python3 -c "
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,14 +91,43 @@ try:
63
91
  except Exception:
64
92
  pass
65
93
  " 2>/dev/null || true
66
- fi
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 ──────────────────
70
- # Write an actual session_diary entry (not draft) with mechanical summary
71
- # This is the parachute — if the LLM never wrote a diary, at least this exists
72
- if [ -f "$NEXO_DB" ]; then
73
- python3 -c "
120
+ # Write an actual session_diary entry (not draft) with mechanical summary.
121
+ # This is the parachute — if the LLM never wrote a diary, at least this exists.
122
+ #
123
+ # v7.8.1 hotfix: must target the EXACT session compacting. Pre-v7.8.1 used
124
+ # "latest active session" (ORDER BY last_update_epoch DESC LIMIT 1) which in
125
+ # multi-conversation Desktop routinely wrote the diary onto the wrong conv.
126
+ # Now we read TARGET_SID resolved from CLAUDE_SESSION_ID above. If it is
127
+ # missing or does not exist in sessions, we fail-closed: no emergency diary
128
+ # > wrong emergency diary. Layer 3 auto_flush is wired to the same SID.
129
+ if [ -f "$NEXO_DB" ] && [ -n "$TARGET_SID" ] && [[ "$TARGET_SID" =~ ^nexo-[0-9]+-[0-9]+$ ]]; then
130
+ NEXO_PRECOMPACT_SID="$TARGET_SID" python3 -c "
74
131
  import json, sqlite3, os, sys
75
132
  from datetime import datetime
76
133
 
@@ -80,15 +137,21 @@ log_file = '$LOG_FILE'
80
137
  conn = sqlite3.connect(db_path, timeout=3)
81
138
  conn.row_factory = sqlite3.Row
82
139
 
83
- # Get latest active session
140
+ # v7.8.1 use the EXACT SID resolved by Layer 1 (from CLAUDE_SESSION_ID),
141
+ # not the pre-v7.8 latest-active row which routinely belongs to a
142
+ # different conversation.
143
+ sid = os.environ.get('NEXO_PRECOMPACT_SID', '')
144
+ if not sid:
145
+ conn.close()
146
+ sys.exit(0)
84
147
  row = conn.execute(
85
- 'SELECT sid, task FROM sessions ORDER BY last_update_epoch DESC LIMIT 1'
148
+ 'SELECT sid, task FROM sessions WHERE sid = ? LIMIT 1', (sid,)
86
149
  ).fetchone()
87
150
  if not row:
151
+ # SID not present — fail-closed, better skip than write against the
152
+ # wrong session.
88
153
  conn.close()
89
154
  sys.exit(0)
90
-
91
- sid = row['sid']
92
155
  task = row['task'] or 'unknown'
93
156
 
94
157
  # Check if a real diary already exists for this session
@@ -99,9 +162,12 @@ if has_diary:
99
162
  conn.close()
100
163
  sys.exit(0) # LLM already wrote one, no need for emergency diary
101
164
 
102
- # Find last diary timestamp to know where to start reading logs
165
+ # v7.8.1 last_diary_ts must be scoped to THIS session too. Otherwise
166
+ # a recent diary of another conversation would truncate this conv's
167
+ # mechanical summary window.
103
168
  last_diary = conn.execute(
104
- 'SELECT created_at FROM session_diary ORDER BY created_at DESC LIMIT 1'
169
+ 'SELECT created_at FROM session_diary WHERE session_id = ? ORDER BY created_at DESC LIMIT 1',
170
+ (sid,),
105
171
  ).fetchone()
106
172
  last_diary_ts = last_diary['created_at'] if last_diary else '1970-01-01T00:00:00Z'
107
173