nexo-brain 7.6.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 +5 -1
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/src/enforcement_engine.py +217 -7
- 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
- package/src/presets/guardian_default.json +4 -3
- package/src/r_catalog.py +63 -17
- package/src/r_primitive_choice.py +111 -0
- package/templates/core-prompts/r-primitive-choice.md +1 -0
- package/templates/core-prompts/r16-declared-done-question.md +1 -1
|
@@ -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,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.
|
|
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`.
|
|
24
|
+
|
|
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.
|
|
22
26
|
|
|
23
27
|
Previously in `7.5.0`: minor release that promoted `nexo_lifecycle_event` from ledger + reconciliation authority to **canonical authority of session-end**. Brain now owns the prompt, the sequence, and the timing of diary+stop; Desktop v0.25.0 (closed-source companion) is the conduit that executes Brain's plan against the live Claude process. The new 2-call contract — `nexo_lifecycle_event` returns a versioned `canonical_plan` (resume_session → inject_prompt → stop_session, with stable ids and per-action timeouts) and `nexo_lifecycle_complete_canonical` confirms execution with a per-action results array — replaces polling with explicit acknowledgement. `canonical_plan_id` is deterministic: `sha256(event_id + "|v" + plan_version)[:24]`, so retries reuse the same id. Migration m52 extends `lifecycle_events` with six `canonical_*` columns plus an index; pre-v7.5 rows simply carry NULL. `session_diary` is the dedupe key on re-delivery: if Desktop crashes between executing the inject and sending the complete call, the next `nexo_lifecycle_event` for the same `event_id` checks for a diary written after `canonical_dispatched_at`; if one exists, Brain short-circuits to `already_processed` and refuses to re-dispatch. The seven explicit `delivery_status` values (`accepted`, `processed`, `canonical_pending`, `canonical_done`, `already_processed`, `retryable_error`, `rejected`) give the pipeline a diffable state machine. `switch` and `window-close` stay observational (no plan ever issued, even with a live `session_id`). `nexo lifecycle record` now returns exit code 0 for `canonical_pending`; older wrappers that treated it as an error are incompatible with v7.5. MCP tool count: 262 → 263.
|
|
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.
|
|
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",
|
|
@@ -80,6 +80,11 @@ try:
|
|
|
80
80
|
except ImportError: # pragma: no cover
|
|
81
81
|
_r_catalog_should = None # type: ignore
|
|
82
82
|
|
|
83
|
+
try:
|
|
84
|
+
from r_primitive_choice import should_inject_r_primitive as _r_primitive_should
|
|
85
|
+
except ImportError: # pragma: no cover
|
|
86
|
+
_r_primitive_should = None # type: ignore
|
|
87
|
+
|
|
83
88
|
try:
|
|
84
89
|
from r34_identity_coherence import should_inject_r34 as _r34_should
|
|
85
90
|
except ImportError: # pragma: no cover
|
|
@@ -422,6 +427,9 @@ class HeadlessEnforcer:
|
|
|
422
427
|
# on_event grace windows — per (event_name) counter of messages
|
|
423
428
|
# since event fired without the required tool being called.
|
|
424
429
|
self._on_event_pending: dict[str, dict] = {}
|
|
430
|
+
# v7.7 Gap 1: latch so multi_step_task_detected fires at most once
|
|
431
|
+
# per task cycle. Cleared on skill_match OR task_close.
|
|
432
|
+
self._multi_step_event_fired: bool = False
|
|
425
433
|
|
|
426
434
|
if self.map:
|
|
427
435
|
self._build_indexes()
|
|
@@ -650,6 +658,14 @@ class HeadlessEnforcer:
|
|
|
650
658
|
self._r14_correction_seen_for_turn = True
|
|
651
659
|
_logger.info("[R14 %s] correction detected; window opened for %d tool calls",
|
|
652
660
|
mode.upper(), self._r14_window_remaining)
|
|
661
|
+
# v7.7 Gap 7.2 — wire on_event so the map's
|
|
662
|
+
# `user_correction_without_learning` rule fires in the live
|
|
663
|
+
# stream. grace_messages was set to 0 in map v2.2 so the
|
|
664
|
+
# learning reminder must surface the same turn.
|
|
665
|
+
try:
|
|
666
|
+
self.raise_event("user_correction_without_learning", {"text_hash": hash(text or "")})
|
|
667
|
+
except Exception:
|
|
668
|
+
pass # telemetry-style; never crash R14 detection
|
|
653
669
|
|
|
654
670
|
def _run_session_end_detection(self, text: str, *, detector=None) -> bool:
|
|
655
671
|
if not self._on_end:
|
|
@@ -848,6 +864,13 @@ class HeadlessEnforcer:
|
|
|
848
864
|
return
|
|
849
865
|
self._enqueue(_R16_PROMPT, "r16:declared-done-without-close", rule_id="R16_declared_done")
|
|
850
866
|
_logger.info("[R16 %s] enqueued declared-done reminder", mode.upper())
|
|
867
|
+
# v7.7 Gap 7.2 — fire the on_event rule wired to task_close so
|
|
868
|
+
# the map's `done_claimed_with_open_task` trigger actually runs
|
|
869
|
+
# from the live stream, not only via test harnesses.
|
|
870
|
+
try:
|
|
871
|
+
self.raise_event("done_claimed_with_open_task", {"source": "R16"})
|
|
872
|
+
except Exception:
|
|
873
|
+
pass
|
|
851
874
|
|
|
852
875
|
def _r25_context(self) -> tuple[set[str], list[str]]:
|
|
853
876
|
"""Resolve the (read_only_hosts, destructive_patterns) pair from
|
|
@@ -1753,8 +1776,15 @@ class HeadlessEnforcer:
|
|
|
1753
1776
|
self._enqueue(prompt, decision["tag"], rule_id="R22_personal_script")
|
|
1754
1777
|
_logger.info("[R22 %s] enqueued path=%s missing=%s", mode.upper(), decision["path"], decision["missing"])
|
|
1755
1778
|
|
|
1756
|
-
def _check_r_catalog(self, tool_name: str):
|
|
1757
|
-
"""R-CATALOG
|
|
1779
|
+
def _check_r_catalog(self, tool_name: str, files: list[str] | None = None):
|
|
1780
|
+
"""R-CATALOG — pre-create discovery probe.
|
|
1781
|
+
|
|
1782
|
+
v7.7 Gap 3: the trigger set is now {nexo_*_create/_open/_add}
|
|
1783
|
+
UNION {Edit / Write into artefact-bearing paths}. The caller
|
|
1784
|
+
passes the extracted file list so plain Edit/Write materialising
|
|
1785
|
+
a skill / plugin / script without going through a dedicated MCP
|
|
1786
|
+
tool still triggers the probe.
|
|
1787
|
+
"""
|
|
1758
1788
|
if _r_catalog_should is None:
|
|
1759
1789
|
return
|
|
1760
1790
|
mode = self._guardian_rule_mode("R_CATALOG_before_artifact_create")
|
|
@@ -1768,7 +1798,7 @@ class HeadlessEnforcer:
|
|
|
1768
1798
|
r.tool for r in self.recent_tool_records[:-1]
|
|
1769
1799
|
if (now - getattr(r, "ts", now)) <= window
|
|
1770
1800
|
]
|
|
1771
|
-
should, prompt = _r_catalog_should(tool_name, recent_tool_names=names)
|
|
1801
|
+
should, prompt = _r_catalog_should(tool_name, recent_tool_names=names, files=files or [])
|
|
1772
1802
|
if not should:
|
|
1773
1803
|
return
|
|
1774
1804
|
if mode == "shadow":
|
|
@@ -1777,6 +1807,45 @@ class HeadlessEnforcer:
|
|
|
1777
1807
|
self._enqueue(prompt, f"R_CATALOG:{tool_name}", rule_id="R_CATALOG_before_artifact_create")
|
|
1778
1808
|
_logger.info("[R_CATALOG %s] enqueued tool=%s", mode.upper(), tool_name)
|
|
1779
1809
|
|
|
1810
|
+
def _check_r_primitive_choice(self, tool_name: str, files: list[str] | None):
|
|
1811
|
+
"""R_PRIMITIVE_CHOICE (v7.7 Gap 4) — SK-CREATE-NEXO-PRIMITIVE gate.
|
|
1812
|
+
|
|
1813
|
+
Flags Edit/Write of a NEW artefact file without a recent primitive-
|
|
1814
|
+
choice probe. Does not duplicate R_CATALOG: R_CATALOG fires on
|
|
1815
|
+
every artefact-path write without inventory consultation, while
|
|
1816
|
+
this rule fires only when the file is genuinely new (no prior
|
|
1817
|
+
Read / Grep / Edit on the same path).
|
|
1818
|
+
"""
|
|
1819
|
+
if _r_primitive_should is None:
|
|
1820
|
+
return
|
|
1821
|
+
mode = self._guardian_rule_mode("R_PRIMITIVE_CHOICE")
|
|
1822
|
+
if mode == "off":
|
|
1823
|
+
return
|
|
1824
|
+
window = 120.0
|
|
1825
|
+
now = time.time()
|
|
1826
|
+
names = [
|
|
1827
|
+
r.tool for r in self.recent_tool_records[:-1]
|
|
1828
|
+
if (now - getattr(r, "ts", now)) <= window
|
|
1829
|
+
]
|
|
1830
|
+
records = [r for r in self.recent_tool_records[:-1]]
|
|
1831
|
+
should, prompt = _r_primitive_should(
|
|
1832
|
+
tool_name,
|
|
1833
|
+
files=files or [],
|
|
1834
|
+
recent_tool_names=names,
|
|
1835
|
+
recent_tool_records=records,
|
|
1836
|
+
)
|
|
1837
|
+
if not should:
|
|
1838
|
+
return
|
|
1839
|
+
if mode == "shadow":
|
|
1840
|
+
_logger.info("[R_PRIMITIVE_CHOICE SHADOW] would inject for %s", tool_name)
|
|
1841
|
+
return
|
|
1842
|
+
self._enqueue(
|
|
1843
|
+
prompt,
|
|
1844
|
+
f"R_PRIMITIVE_CHOICE:{tool_name}",
|
|
1845
|
+
rule_id="R_PRIMITIVE_CHOICE",
|
|
1846
|
+
)
|
|
1847
|
+
_logger.info("[R_PRIMITIVE_CHOICE %s] enqueued tool=%s", mode.upper(), tool_name)
|
|
1848
|
+
|
|
1780
1849
|
def _check_r18(self, tool_name: str, tool_input):
|
|
1781
1850
|
"""R18 — suggest followup_complete on closure-class actions."""
|
|
1782
1851
|
if _r18_should is None or _r18_format is None:
|
|
@@ -1934,6 +2003,29 @@ class HeadlessEnforcer:
|
|
|
1934
2003
|
# open tool so the next task cycle re-opens the obligation.
|
|
1935
2004
|
if name == "nexo_task_close":
|
|
1936
2005
|
self.reset_task_cycle("nexo_task_open")
|
|
2006
|
+
|
|
2007
|
+
# v7.7 Gap 1 — autonomous detector for multi_step_task_detected.
|
|
2008
|
+
# The event was dispatched by the map but nothing ever raised it.
|
|
2009
|
+
# Heuristic: three or more edit/execute/delegate calls within the
|
|
2010
|
+
# recent window (Edit/Write/Task/Bash-with-write-command) without
|
|
2011
|
+
# a nexo_skill_match in between signals multi-step work that
|
|
2012
|
+
# should consult skills first. We raise the event at most once per
|
|
2013
|
+
# task cycle — skill_match clears it; task_close rearms it.
|
|
2014
|
+
if not self._multi_step_event_fired:
|
|
2015
|
+
edit_like = {"Edit", "Write", "Task"}
|
|
2016
|
+
recent_edit_calls = sum(
|
|
2017
|
+
1 for r in self.recent_tool_records[-10:] if r.tool in edit_like
|
|
2018
|
+
)
|
|
2019
|
+
if recent_edit_calls >= 3 and "nexo_skill_match" not in self.tools_called:
|
|
2020
|
+
try:
|
|
2021
|
+
self.raise_event("multi_step_task_detected", {"recent_edits": recent_edit_calls})
|
|
2022
|
+
except Exception:
|
|
2023
|
+
pass # telemetry-style; never crash enforcement
|
|
2024
|
+
self._multi_step_event_fired = True
|
|
2025
|
+
if name == "nexo_skill_match" or name == "nexo_task_close":
|
|
2026
|
+
# Both signals clear the multi-step flag so the next task
|
|
2027
|
+
# cycle gets its own detection window.
|
|
2028
|
+
self._multi_step_event_fired = False
|
|
1937
2029
|
# Track the recent tool_use with the file paths it targets so Fase 2
|
|
1938
2030
|
# Capa 2 rules (R13, future R19/R20) can inspect the write path.
|
|
1939
2031
|
files = self._extract_files(tool_input)
|
|
@@ -2004,7 +2096,13 @@ class HeadlessEnforcer:
|
|
|
2004
2096
|
|
|
2005
2097
|
# R-CATALOG (Plan 0.X.2) — nudge if we are about to create/open/add
|
|
2006
2098
|
# without having consulted the live inventory in the last 60 s.
|
|
2007
|
-
self._check_r_catalog(name)
|
|
2099
|
+
self._check_r_catalog(name, files)
|
|
2100
|
+
|
|
2101
|
+
# v7.7 Gap 4 — R_PRIMITIVE_CHOICE. Runs AFTER R_CATALOG because
|
|
2102
|
+
# R_CATALOG's prompt covers the generic inventory case; this one
|
|
2103
|
+
# adds the specific primitive-decision reminder when a brand-new
|
|
2104
|
+
# artefact file is being materialised via Edit/Write.
|
|
2105
|
+
self._check_r_primitive_choice(name, files)
|
|
2008
2106
|
|
|
2009
2107
|
# R18 — retroactive followup-complete suggestion on closure actions.
|
|
2010
2108
|
self._check_r18(name, tool_input)
|
|
@@ -2161,11 +2259,26 @@ class HeadlessEnforcer:
|
|
|
2161
2259
|
|
|
2162
2260
|
def reset_task_cycle(self, tool: str = "nexo_task_open"):
|
|
2163
2261
|
"""Called when a task_close lands, so the conditional counter for
|
|
2164
|
-
the matching open-tool rearms for the next task.
|
|
2165
|
-
|
|
2166
|
-
|
|
2262
|
+
the matching open-tool rearms for the next task.
|
|
2263
|
+
|
|
2264
|
+
v7.7 Gap 7.1 (checklist pass-2 hotfix): v7.6 only reset the
|
|
2265
|
+
counter but left `tools_called` carrying `nexo_task_open` from
|
|
2266
|
+
the previous cycle. That meant `_check_conditional`'s early
|
|
2267
|
+
`if tool in self.tools_called: continue` short-circuit still
|
|
2268
|
+
blocked the re-nudge forever. We now also drop the open-tool
|
|
2269
|
+
from `tools_called` and clear its per-instance pin so the gate
|
|
2270
|
+
genuinely re-arms for the next task cycle. `_tool_last_instance`
|
|
2271
|
+
stays intact for the OTHER tools (per-instance semantics for
|
|
2272
|
+
after_tool still rely on it).
|
|
2167
2273
|
"""
|
|
2168
2274
|
self._conditional_counters[tool] = 0
|
|
2275
|
+
if tool in self.tools_called:
|
|
2276
|
+
self.tools_called.discard(tool)
|
|
2277
|
+
# Clearing the per-instance pin lets future after_tool
|
|
2278
|
+
# dependencies on this tool re-open too; the conditional rule is
|
|
2279
|
+
# what the checklist focused on but the same "satisfied-by-once"
|
|
2280
|
+
# defect applied to after_tool gates pointing at task_open.
|
|
2281
|
+
self._tool_last_instance.pop(tool, None)
|
|
2169
2282
|
|
|
2170
2283
|
def check_periodic(self):
|
|
2171
2284
|
for entry in self._on_start:
|
|
@@ -2198,6 +2311,96 @@ class HeadlessEnforcer:
|
|
|
2198
2311
|
# v7.6 conditional + deferred on_event reminders.
|
|
2199
2312
|
self._check_conditional()
|
|
2200
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
|
|
2201
2404
|
|
|
2202
2405
|
def _check_on_event_pending(self):
|
|
2203
2406
|
"""Re-evaluate on_event rules with grace > 0 after message ticks.
|
|
@@ -2422,10 +2625,17 @@ def run_with_enforcement(
|
|
|
2422
2625
|
if event_type == "assistant" and event.get("message", {}).get("content"):
|
|
2423
2626
|
for block in event["message"]["content"]:
|
|
2424
2627
|
if block.get("type") == "tool_use":
|
|
2628
|
+
# v7.7 Gap 7.3 — wire before_tool in the live
|
|
2629
|
+
# stream. Desktop already calls onBeforeToolCall
|
|
2630
|
+
# before onToolCall; Brain's stream was only
|
|
2631
|
+
# calling on_tool_call, silently skipping every
|
|
2632
|
+
# before_tool rule the map declared.
|
|
2633
|
+
enforcer.on_tool_call_before(block.get("name", ""), block.get("input"))
|
|
2425
2634
|
enforcer.on_tool_call(block.get("name", ""), block.get("input"))
|
|
2426
2635
|
elif event_type == "content_block_start":
|
|
2427
2636
|
cb = event.get("content_block", {})
|
|
2428
2637
|
if cb.get("type") == "tool_use":
|
|
2638
|
+
enforcer.on_tool_call_before(cb.get("name", ""), cb.get("input"))
|
|
2429
2639
|
enforcer.on_tool_call(cb.get("name", ""), cb.get("input"))
|
|
2430
2640
|
|
|
2431
2641
|
if event_type == "assistant" and not waiting_for_injection_response:
|
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 ──────────────────
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://nexo-brain.com/schemas/guardian-config-v1.json",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30) can only be shadow/soft/hard — mode=off is rejected by validator. v1.4.0 (constructor-guardian-90
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Default Guardian configuration. Copied to ~/.nexo/personal/config/guardian.json at `nexo init` / `nexo update` if no user config exists. If user config exists, merge keys NOT present in user config without overwriting user overrides. Core rules (R13, R14, R16, R25, R30) can only be shadow/soft/hard — mode=off is rejected by validator. v1.4.0 (constructor-guardian-90 pass 1): R15/R17/R22/R_CATALOG upgraded from soft to hard where FP telemetry was low; R34 raised from shadow to soft. v1.5.0 (pass 2): R11_plugin_load_pre_inventory upgraded from soft to hard; new R_PRIMITIVE_CHOICE (SK-CREATE-NEXO-PRIMITIVE gate) added at soft so it can be observed before hardening.",
|
|
5
5
|
"enabled": true,
|
|
6
6
|
"classifier_task_profile": "enforcer_classify",
|
|
7
7
|
"classifier_tier": "muy_bajo",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"R08_reminder_recurrence_conflict": "soft",
|
|
24
24
|
"R09_artifact_create_dedup": "soft",
|
|
25
25
|
"R10_workflow_open_without_task": "hard",
|
|
26
|
-
"R11_plugin_load_pre_inventory": "
|
|
26
|
+
"R11_plugin_load_pre_inventory": "hard",
|
|
27
27
|
"R12_cognitive_write_dedup": "soft",
|
|
28
28
|
"R13_pre_edit_guard": "hard",
|
|
29
29
|
"R14_correction_learning": "hard",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"R25_nora_maria_read_only": "hard",
|
|
44
44
|
"R30_pre_done_evidence_system_prompt": "hard",
|
|
45
45
|
"R_CATALOG_before_artifact_create": "hard",
|
|
46
|
+
"R_PRIMITIVE_CHOICE": "soft",
|
|
46
47
|
"R26_no_internal_jargon": "hard",
|
|
47
48
|
"R27_concise_responses": "hard",
|
|
48
49
|
"R28_correction_learning_system_prompt": "hard",
|
package/src/r_catalog.py
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
"""R-CATALOG — Plan Consolidado 0.X.2.
|
|
1
|
+
"""R-CATALOG — Plan Consolidado 0.X.2 + v7.7 Gap 3 expansion.
|
|
2
2
|
|
|
3
|
-
Pre-create discovery probe
|
|
4
|
-
(``nexo_*_create`` family) without having consulted any inventory tool in
|
|
5
|
-
the same turn, R-CATALOG nudges it to search first.
|
|
3
|
+
Pre-create discovery probe. Two trigger families:
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
(a) `nexo_*_create` / `_open` / `_add` — the original MCP-tool path.
|
|
6
|
+
(b) v7.7: `Edit` / `Write` writing into artefact-bearing paths
|
|
7
|
+
(skills/, plugins/, scripts/, personal scripts). The checklist
|
|
8
|
+
item "ampliar el ámbito del pre-probe de catálogo para cubrir
|
|
9
|
+
no solo nexo_*_create/_open/_add, sino también writes de
|
|
10
|
+
archivos que materializan skills/plugins/scripts/plantillas/
|
|
11
|
+
artefactos aunque no hayan pasado por un tool MCP de 'create'".
|
|
12
|
+
|
|
13
|
+
Rationale: the Guardian should not re-teach what tools exist; the live
|
|
14
|
+
catalog does that. But if the agent materialises a new skill / plugin /
|
|
15
|
+
script by writing a file directly, without consulting inventory, it
|
|
16
|
+
skips discovery and produces duplicates.
|
|
12
17
|
|
|
13
18
|
Contract:
|
|
14
|
-
- Trigger tools:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
- Trigger tools: `nexo_*_create` / `_open` / `_add` (always), or
|
|
20
|
+
`Edit` / `Write` when the file path lives under a recognised
|
|
21
|
+
artefact root.
|
|
22
|
+
- Discovery tools (any one resets the window): `nexo_system_catalog`,
|
|
23
|
+
`nexo_tool_explain`, `nexo_skill_match`, `nexo_skill_list`,
|
|
24
|
+
`nexo_learning_search`, `nexo_guard_check`, `nexo_personal_scripts_list`,
|
|
25
|
+
`nexo_plugin_list`.
|
|
26
|
+
- Window: 60s. Caller passes `recent_tool_names` already filtered to
|
|
19
27
|
the last 60 seconds so the rule itself is time-agnostic.
|
|
20
|
-
- Dedup tag: the engine's _enqueue keys dedup by rule_id + tag so an
|
|
21
|
-
agent chaining two creates only gets nudged once per 60s.
|
|
22
28
|
"""
|
|
23
29
|
from __future__ import annotations
|
|
24
30
|
|
|
@@ -34,8 +40,30 @@ DISCOVERY_TOOLS = frozenset({
|
|
|
34
40
|
"nexo_skill_list",
|
|
35
41
|
"nexo_learning_search",
|
|
36
42
|
"nexo_guard_check",
|
|
43
|
+
# v7.7 Gap 3: inventory surfaces that count as "I checked what
|
|
44
|
+
# already exists before writing a new artefact".
|
|
45
|
+
"nexo_personal_scripts_list",
|
|
46
|
+
"nexo_plugin_list",
|
|
37
47
|
})
|
|
38
48
|
|
|
49
|
+
# Path fragments that classify a write as an "artefact creation" write,
|
|
50
|
+
# even when it goes through plain Edit / Write instead of a dedicated
|
|
51
|
+
# MCP tool. v7.7 Gap 3 coverage.
|
|
52
|
+
ARTEFACT_PATH_FRAGMENTS = (
|
|
53
|
+
"/skills/",
|
|
54
|
+
"/clawhub-skill/",
|
|
55
|
+
"/.claude-plugin/",
|
|
56
|
+
"/src/plugins/",
|
|
57
|
+
"/personal/scripts/",
|
|
58
|
+
"/personal/skills/",
|
|
59
|
+
"/personal-scripts/",
|
|
60
|
+
"/.nexo/personal/scripts/",
|
|
61
|
+
"/.nexo/skills/",
|
|
62
|
+
"/templates/core-prompts/",
|
|
63
|
+
"/core-prompts/",
|
|
64
|
+
"/src/presets/",
|
|
65
|
+
)
|
|
66
|
+
|
|
39
67
|
INJECTION_PROMPT = render_core_prompt("r-catalog", tool="{tool}")
|
|
40
68
|
|
|
41
69
|
|
|
@@ -45,13 +73,30 @@ def _is_trigger_tool(tool_name) -> bool:
|
|
|
45
73
|
return tool_name.endswith("_create") or tool_name.endswith("_open") or tool_name.endswith("_add")
|
|
46
74
|
|
|
47
75
|
|
|
76
|
+
def _is_artefact_write(tool_name, files: Iterable[str] | None) -> bool:
|
|
77
|
+
"""v7.7 Gap 3: a plain Edit/Write into an artefact-bearing path
|
|
78
|
+
counts as a trigger even without a *_create MCP tool."""
|
|
79
|
+
if tool_name not in ("Edit", "Write"):
|
|
80
|
+
return False
|
|
81
|
+
if not files:
|
|
82
|
+
return False
|
|
83
|
+
for path in files:
|
|
84
|
+
if not isinstance(path, str):
|
|
85
|
+
continue
|
|
86
|
+
for fragment in ARTEFACT_PATH_FRAGMENTS:
|
|
87
|
+
if fragment in path:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
48
92
|
def should_inject_r_catalog(
|
|
49
93
|
tool_name,
|
|
50
94
|
*,
|
|
51
95
|
recent_tool_names: Iterable[str] | None,
|
|
96
|
+
files: Iterable[str] | None = None,
|
|
52
97
|
) -> tuple[bool, str]:
|
|
53
98
|
"""Return (inject, prompt). Never raises."""
|
|
54
|
-
if not _is_trigger_tool(tool_name):
|
|
99
|
+
if not _is_trigger_tool(tool_name) and not _is_artefact_write(tool_name, files):
|
|
55
100
|
return False, ""
|
|
56
101
|
if tool_name in DISCOVERY_TOOLS:
|
|
57
102
|
return False, ""
|
|
@@ -63,6 +108,7 @@ def should_inject_r_catalog(
|
|
|
63
108
|
|
|
64
109
|
__all__ = [
|
|
65
110
|
"DISCOVERY_TOOLS",
|
|
111
|
+
"ARTEFACT_PATH_FRAGMENTS",
|
|
66
112
|
"INJECTION_PROMPT",
|
|
67
113
|
"should_inject_r_catalog",
|
|
68
114
|
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""R_PRIMITIVE_CHOICE — v7.7 Gap 4.
|
|
2
|
+
|
|
3
|
+
Before materialising a NEW artefact (skill / plugin / personal script /
|
|
4
|
+
template) via plain Edit / Write, the agent MUST consult SK-CREATE-
|
|
5
|
+
NEXO-PRIMITIVE (or its equivalent signal — a recent `nexo_skill_match`
|
|
6
|
+
/ `nexo_tool_explain` call) so skill-vs-plugin-vs-script-vs-schedule-
|
|
7
|
+
vs-core-change is not improvised.
|
|
8
|
+
|
|
9
|
+
Distinct from R_CATALOG (v7.7 Gap 3) which fires on EVERY write into
|
|
10
|
+
an artefact-bearing path; R_PRIMITIVE_CHOICE fires only when the file
|
|
11
|
+
is NEW (write without prior matching read/grep) so editing an existing
|
|
12
|
+
skill to tweak a step is not reprimanded.
|
|
13
|
+
|
|
14
|
+
Contract:
|
|
15
|
+
- Trigger: Edit/Write into artefact-bearing paths AND the file did
|
|
16
|
+
NOT appear in the recent tool record as a previous Read/Grep.
|
|
17
|
+
- Relief signal (any one in last 120s): `nexo_skill_match`,
|
|
18
|
+
`nexo_tool_explain`, Read or Grep of the same path, or
|
|
19
|
+
`nexo_skill_apply` referencing SK-CREATE-NEXO-PRIMITIVE.
|
|
20
|
+
- Default mode: soft (so the rule can be observed before hardening).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Iterable
|
|
25
|
+
|
|
26
|
+
from core_prompts import render_core_prompt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ARTEFACT_PATH_FRAGMENTS = (
|
|
30
|
+
"/skills/",
|
|
31
|
+
"/clawhub-skill/",
|
|
32
|
+
"/.claude-plugin/",
|
|
33
|
+
"/src/plugins/",
|
|
34
|
+
"/personal/scripts/",
|
|
35
|
+
"/personal/skills/",
|
|
36
|
+
"/personal-scripts/",
|
|
37
|
+
"/.nexo/personal/scripts/",
|
|
38
|
+
"/.nexo/skills/",
|
|
39
|
+
"/templates/core-prompts/",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
RELIEF_TOOLS = frozenset({
|
|
43
|
+
"nexo_skill_match",
|
|
44
|
+
"nexo_tool_explain",
|
|
45
|
+
"nexo_skill_apply",
|
|
46
|
+
"Read",
|
|
47
|
+
"Grep",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
INJECTION_PROMPT = render_core_prompt("r-primitive-choice", path="{path}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_artefact_path(path: str) -> bool:
|
|
55
|
+
if not isinstance(path, str):
|
|
56
|
+
return False
|
|
57
|
+
return any(fragment in path for fragment in ARTEFACT_PATH_FRAGMENTS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _file_previously_touched(path: str, recent_records) -> bool:
|
|
61
|
+
"""True iff the same path was seen in a prior Read / Grep / Edit
|
|
62
|
+
call inside the recent window. The engine passes its
|
|
63
|
+
recent_tool_records list (each record has .tool and .files)."""
|
|
64
|
+
if not isinstance(path, str):
|
|
65
|
+
return False
|
|
66
|
+
for record in recent_records or []:
|
|
67
|
+
tool = getattr(record, "tool", "")
|
|
68
|
+
if tool not in ("Read", "Grep", "Edit"):
|
|
69
|
+
continue
|
|
70
|
+
files = getattr(record, "files", ())
|
|
71
|
+
for f in files or ():
|
|
72
|
+
if f == path:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def should_inject_r_primitive(
|
|
78
|
+
tool_name,
|
|
79
|
+
*,
|
|
80
|
+
files: Iterable[str] | None,
|
|
81
|
+
recent_tool_names: Iterable[str] | None,
|
|
82
|
+
recent_tool_records,
|
|
83
|
+
) -> tuple[bool, str]:
|
|
84
|
+
"""Return (inject, prompt). Fail-closed on bad input → (False, "")."""
|
|
85
|
+
if tool_name not in ("Edit", "Write"):
|
|
86
|
+
return False, ""
|
|
87
|
+
file_list = [f for f in (files or []) if isinstance(f, str)]
|
|
88
|
+
if not file_list:
|
|
89
|
+
return False, ""
|
|
90
|
+
artefact_files = [f for f in file_list if _is_artefact_path(f)]
|
|
91
|
+
if not artefact_files:
|
|
92
|
+
return False, ""
|
|
93
|
+
# If any relief tool fired recently, rule is satisfied.
|
|
94
|
+
recent = set(recent_tool_names or [])
|
|
95
|
+
if recent & RELIEF_TOOLS:
|
|
96
|
+
return False, ""
|
|
97
|
+
# If the file appears to be an edit of an existing artefact (Read
|
|
98
|
+
# or Grep already touched the same path), treat as relief too —
|
|
99
|
+
# we only want to flag NEW artefact creation.
|
|
100
|
+
for f in artefact_files:
|
|
101
|
+
if _file_previously_touched(f, recent_tool_records):
|
|
102
|
+
return False, ""
|
|
103
|
+
return True, INJECTION_PROMPT.format(path=artefact_files[0])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"ARTEFACT_PATH_FRAGMENTS",
|
|
108
|
+
"RELIEF_TOOLS",
|
|
109
|
+
"INJECTION_PROMPT",
|
|
110
|
+
"should_inject_r_primitive",
|
|
111
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[NEXO Protocol Enforcer] R_PRIMITIVE_CHOICE: you are about to write a new artefact under `{path}` without consulting SK-CREATE-NEXO-PRIMITIVE or an equivalent primitive-choice probe. Stop. Run `nexo_skill_match` with the phrase "create a new primitive" (or the equivalent in your language) and follow the skill's decision table before creating. skill vs plugin vs personal-script vs schedule vs core-change is a durable decision — do not improvise. Do not produce visible text for this reminder; act on it silently.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Does the assistant message below declare that a task is finished, completed, shipped, or
|
|
1
|
+
Does the assistant message below declare that a task is finished, completed, shipped, sent, delivered, published, deployed, released, fixed, resolved, merged, pushed, handed off, or otherwise closed on any plane (deploy, release, publish, email/message sent, PR merged, fix applied, task resolved)? Answer yes only if the assistant is claiming real completion of the current work (in any language — Spanish "listo/hecho/terminado/enviado/arreglado/desplegado/publicado/lanzado/resuelto/mergeado" counts equally). Answer no for status updates, mid-task progress reports, questions, partial summaries, or "I will now..." promises without execution in the same turn.
|