nexo-brain 7.23.11 → 7.23.13

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.23.11",
3
+ "version": "7.23.13",
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,11 +18,13 @@
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.23.11` is the current packaged-runtime line. Patch over v7.23.10 - older installed runtimes can update safely even when `cognitive_paths.py` has not been synced yet.
21
+ Version `7.23.13` is the current packaged-runtime line. Patch over v7.23.12 - release guardrails now audit publish workflows for masked failures and add minimal-delta coverage for punctual UI edits.
22
22
 
23
- Previously in `7.23.6`: patch over v7.23.5 - `nexo update` clears safe legacy `cognitive.db` shadows and keeps superseded archives under runtime backup retention.
23
+ Previously in `7.23.12`: patch over v7.23.11 - protected database recovery now repairs degraded Brain tables from backup without rolling back newer rows.
24
+
25
+ Previously in `7.23.11`: patch over v7.23.10 - older installed runtimes can update safely even when `cognitive_paths.py` has not been synced yet.
24
26
 
25
- Previously in `7.23.5`: patch over v7.23.4 - `nexo update` keeps external CLI maintenance summary copy in English.
27
+ Previously in `7.23.6`: patch over v7.23.5 - `nexo update` clears safe legacy `cognitive.db` shadows and keeps superseded archives under runtime backup retention.
26
28
 
27
29
  Previously in `7.23.3`: patch over v7.23.2 - Followup runner skips DONE terminal statuses so already-finished followups do not re-enter executable batches.
28
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.11",
3
+ "version": "7.23.13",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — 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",
package/src/db_guard.py CHANGED
@@ -29,7 +29,7 @@ auto_update.py):
29
29
  diff_row_counts(current, reference, tables) -> WipeReport
30
30
  safe_sqlite_backup(source, dest) -> tuple[bool, str | None]
31
31
  validate_backup_matches_source(source, dest, tables) -> tuple[bool, str | None]
32
- restore_tables_from_backup(source, target, tables) -> dict
32
+ restore_tables_from_backup(source, target, tables, mode) -> dict
33
33
  kill_nexo_mcp_servers(dry_run) -> dict
34
34
  quiesce_nexo_db_writers(dry_run) -> dict
35
35
  resume_nexo_launchagents(labels, dry_run) -> dict
@@ -501,23 +501,42 @@ def _quote_identifier(identifier: str) -> str:
501
501
  return '"' + identifier.replace('"', '""') + '"'
502
502
 
503
503
 
504
+ def _quote_sql_name(identifier: str) -> str:
505
+ return '"' + identifier.replace('"', '""') + '"'
506
+
507
+
508
+ def _table_columns(conn: sqlite3.Connection, schema: str, table: str) -> list[str]:
509
+ if schema not in {"main", "backup_db"}:
510
+ raise ValueError(f"refusing unsafe schema identifier: {schema!r}")
511
+ quoted = _quote_identifier(table)
512
+ rows = conn.execute(f"PRAGMA {schema}.table_info({quoted})").fetchall()
513
+ return [str(row[1]) for row in rows]
514
+
515
+
504
516
  def restore_tables_from_backup(
505
517
  source: str | Path,
506
518
  target: str | Path,
507
519
  tables: tuple[str, ...] = LOCAL_CONTEXT_TABLES,
520
+ *,
521
+ mode: str = "replace",
508
522
  ) -> dict:
509
- """Replace selected tables in ``target`` with the copy from ``source``.
523
+ """Restore selected tables in ``target`` from ``source``.
510
524
 
511
- This is intentionally table-scoped. It lets Doctor/repair recover days of
512
- local indexing from a backup without rolling back newer conversations,
513
- credentials, followups, or other Brain state created after that backup.
525
+ ``mode="replace"`` keeps the historical behavior: target rows are deleted
526
+ and replaced by the backup table. ``mode="merge_missing"`` preserves target
527
+ rows and inserts missing rows from the backup with ``INSERT OR IGNORE``.
528
+ This is intentionally table-scoped so repair can recover data without
529
+ rolling back unrelated Brain state created after the backup.
514
530
  """
531
+ if mode not in {"replace", "merge_missing"}:
532
+ raise ValueError(f"unsupported restore mode: {mode!r}")
515
533
  src = Path(source)
516
534
  dst = Path(target)
517
535
  result: dict = {
518
536
  "ok": False,
519
537
  "source": str(src),
520
538
  "target": str(dst),
539
+ "mode": mode,
521
540
  "tables": {},
522
541
  "errors": [],
523
542
  }
@@ -553,13 +572,29 @@ def restore_tables_from_backup(
553
572
  continue
554
573
  conn.execute(create_sql)
555
574
  before = _table_count(conn, table) or 0
556
- conn.execute(f"DELETE FROM main.{quoted}")
557
- conn.execute(f"INSERT INTO main.{quoted} SELECT * FROM backup_db.{quoted}")
575
+ if mode == "replace":
576
+ conn.execute(f"DELETE FROM main.{quoted}")
577
+ conn.execute(f"INSERT INTO main.{quoted} SELECT * FROM backup_db.{quoted}")
578
+ status = "restored"
579
+ else:
580
+ target_columns = _table_columns(conn, "main", table)
581
+ source_columns = set(_table_columns(conn, "backup_db", table))
582
+ common_columns = [column for column in target_columns if column in source_columns]
583
+ if not common_columns:
584
+ result["tables"][table] = {"status": "no_common_columns", "before": int(before)}
585
+ continue
586
+ column_sql = ", ".join(_quote_sql_name(column) for column in common_columns)
587
+ conn.execute(
588
+ f"INSERT OR IGNORE INTO main.{quoted} ({column_sql}) "
589
+ f"SELECT {column_sql} FROM backup_db.{quoted}"
590
+ )
591
+ status = "merged"
558
592
  after = _table_count(conn, table) or 0
559
593
  result["tables"][table] = {
560
- "status": "restored",
594
+ "status": status,
561
595
  "before": int(before),
562
596
  "after": int(after),
597
+ "restored": max(int(after) - int(before), 0),
563
598
  }
564
599
  conn.commit()
565
600
  result["ok"] = not result["errors"]
@@ -202,21 +202,26 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
202
202
  evidence.extend(["Local memory regression:", *local_regression.summary_lines()])
203
203
 
204
204
  if fix and recoverable_regression:
205
- report = restore_tables_from_backup(reference, db_path)
205
+ report = restore_tables_from_backup(
206
+ reference,
207
+ db_path,
208
+ tables=PROTECTED_TABLES,
209
+ mode="merge_missing",
210
+ )
206
211
  if report.get("ok"):
207
212
  restored = {
208
213
  table: payload
209
214
  for table, payload in (report.get("tables") or {}).items()
210
- if isinstance(payload, dict) and payload.get("status") == "restored"
215
+ if isinstance(payload, dict) and payload.get("status") in {"restored", "merged"}
211
216
  }
212
- restored_rows = sum(int(payload.get("after") or 0) for payload in restored.values())
217
+ restored_rows = sum(int(payload.get("restored") or 0) for payload in restored.values())
213
218
  return DoctorCheck(
214
219
  id="boot.db_integrity",
215
220
  tier="boot",
216
221
  status="healthy",
217
222
  severity="info",
218
- summary=f"Local memory restored from backup ({restored_rows} protected rows)",
219
- evidence=evidence + [f"Restored local-memory tables: {len(restored)}"],
223
+ summary=f"Database protected tables restored from backup ({restored_rows} rows recovered)",
224
+ evidence=evidence + [f"Restored protected tables: {len(restored)}"],
220
225
  fixed=True,
221
226
  )
222
227
  return DoctorCheck(
@@ -224,7 +229,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
224
229
  tier="boot",
225
230
  status="critical",
226
231
  severity="error",
227
- summary="Local memory repair failed",
232
+ summary="Database protected-table repair failed",
228
233
  evidence=evidence + [f"Restore errors: {report.get('errors') or []}"],
229
234
  repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
230
235
  )
File without changes
@@ -0,0 +1,267 @@
1
+ """Minimal-delta pre-mutation guardrail (NF-DS-C3E64B2B).
2
+
3
+ Purpose
4
+ -------
5
+ Block scope creep when the operator describes a *punctual* UI change
6
+ ("add this text", "change the size", "adjust the color") but the agent
7
+ proposes a diff that touches many unrelated lines.
8
+
9
+ How it plugs in
10
+ ---------------
11
+ Wire ``check`` from ``hook_guardrails.process_pre_tool_event`` BEFORE the
12
+ ``Edit`` / ``Write`` call reaches the model. The hook should:
13
+
14
+ 1. Call ``classify_request(prompt_text)`` to detect punctual UI verbs.
15
+ 2. If punctual, capture the *target file* + *proposed new_string* from the
16
+ pending tool payload.
17
+ 3. Read ``read_file_history(path)`` to keep the prior context available
18
+ (it returns the last 5 commits + current text so the agent can reason
19
+ in bullet-by-bullet form when replying).
20
+ 4. Call ``check_diff(prompt_text, old_text, new_text)`` and act on the
21
+ returned ``GuardDecision``:
22
+
23
+ * ``decision == "allow"`` → let the tool through, no annotation.
24
+ * ``decision == "warn"`` → let it through but tell the agent in the
25
+ response payload how many extra lines it touched.
26
+ * ``decision == "block"`` → deny the tool with the included reason.
27
+ The agent must reply with a bullet-by-bullet diff against the prior
28
+ state and request explicit confirmation before retrying.
29
+
30
+ Why headless-friendly
31
+ ---------------------
32
+ This module is *pure*. No filesystem, no subprocess, no MCP call. The hook
33
+ layer wires it in. That keeps the gate testable in isolation and means a
34
+ wrong tuning here cannot brick the pre-tool pipeline.
35
+
36
+ Origin: Deep Sleep followup NF-DS-C3E64B2B (scope creep in punctual visual
37
+ changes).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import difflib
42
+ import re
43
+ import subprocess
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+ from typing import Iterable
47
+
48
+ # Verbs that signal a *punctual* UI change. Matched case-insensitive against
49
+ # the full request text. Spanish terms are intentionally included because the
50
+ # operator often reports UI issues in Spanish; output remains English.
51
+ PUNCTUAL_VERBS: tuple[str, ...] = (
52
+ "añade",
53
+ "anade",
54
+ "agrega",
55
+ "añadir",
56
+ "agrega el texto",
57
+ "cambia el texto",
58
+ "cambia el color",
59
+ "cambia el tamaño",
60
+ "cambia el tamano",
61
+ "ajusta el tamaño",
62
+ "ajusta el tamano",
63
+ "ajusta el padding",
64
+ "ajusta el margen",
65
+ "ajusta el color",
66
+ "renombra",
67
+ "rename",
68
+ "tweak",
69
+ "change the text",
70
+ "change the color",
71
+ "adjust the size",
72
+ "fix the label",
73
+ "fix the wording",
74
+ "add the text",
75
+ "swap the icon",
76
+ )
77
+
78
+ # File extensions considered UI surfaces. Anything else short-circuits to
79
+ # "not a UI mutation" so we never block server code on accident.
80
+ UI_EXTENSIONS: frozenset[str] = frozenset({
81
+ ".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".astro",
82
+ ".html", ".css", ".scss", ".sass", ".less",
83
+ ".liquid", ".njk", ".hbs",
84
+ })
85
+
86
+ # Default threshold for "unrelated lines". Tuned conservatively — a real UI
87
+ # tweak usually changes 1-3 contiguous lines. Anything > THRESHOLD blocks.
88
+ DEFAULT_THRESHOLD = 8
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class GuardDecision:
93
+ decision: str # "allow" | "warn" | "block"
94
+ matched_verb: str | None
95
+ changed_lines: int
96
+ threshold: int
97
+ reason: str
98
+
99
+ def to_payload(self) -> dict:
100
+ return {
101
+ "guard": "minimal-delta",
102
+ "decision": self.decision,
103
+ "matched_verb": self.matched_verb,
104
+ "changed_lines": self.changed_lines,
105
+ "threshold": self.threshold,
106
+ "reason": self.reason,
107
+ }
108
+
109
+
110
+ def classify_request(prompt_text: str) -> str | None:
111
+ """Return the matched punctual verb (lowercase) or ``None``."""
112
+ if not prompt_text:
113
+ return None
114
+ haystack = prompt_text.lower()
115
+ for verb in PUNCTUAL_VERBS:
116
+ if verb in haystack:
117
+ return verb
118
+ return None
119
+
120
+
121
+ def is_ui_path(path: str | Path) -> bool:
122
+ if not path:
123
+ return False
124
+ suffix = Path(str(path)).suffix.lower()
125
+ return suffix in UI_EXTENSIONS
126
+
127
+
128
+ def count_changed_lines(old_text: str, new_text: str) -> int:
129
+ """Count *non-context* diff lines (+/− only)."""
130
+ if old_text == new_text:
131
+ return 0
132
+ old_lines = old_text.splitlines()
133
+ new_lines = new_text.splitlines()
134
+ diff = difflib.unified_diff(old_lines, new_lines, n=0, lineterm="")
135
+ count = 0
136
+ for line in diff:
137
+ if line.startswith(("+++", "---", "@@")):
138
+ continue
139
+ if line.startswith(("+", "-")):
140
+ count += 1
141
+ return count
142
+
143
+
144
+ def check_diff(
145
+ prompt_text: str,
146
+ old_text: str,
147
+ new_text: str,
148
+ target_path: str | Path = "",
149
+ *,
150
+ threshold: int = DEFAULT_THRESHOLD,
151
+ ) -> GuardDecision:
152
+ verb = classify_request(prompt_text)
153
+ if verb is None:
154
+ return GuardDecision("allow", None, 0, threshold, "Not a punctual request — guard not applied.")
155
+ if target_path and not is_ui_path(target_path):
156
+ return GuardDecision(
157
+ "allow", verb, 0, threshold,
158
+ f"Target {target_path} is not a UI surface — guard not applied.",
159
+ )
160
+ changed = count_changed_lines(old_text, new_text)
161
+ if changed <= 2:
162
+ return GuardDecision(
163
+ "allow", verb, changed, threshold,
164
+ "Diff stays within 2 lines — within the punctual envelope.",
165
+ )
166
+ if changed <= threshold:
167
+ return GuardDecision(
168
+ "warn", verb, changed, threshold,
169
+ (
170
+ f"Punctual request ('{verb}') is changing {changed} lines. "
171
+ "Inside the soft envelope but please justify each line in the reply."
172
+ ),
173
+ )
174
+ return GuardDecision(
175
+ "block", verb, changed, threshold,
176
+ (
177
+ f"Punctual request ('{verb}') is changing {changed} lines (>{threshold}). "
178
+ "Read git log/blame for the file, present the prior state, list each proposed "
179
+ "change as a bullet, and ask the operator before applying. Override only with "
180
+ "explicit confirmation."
181
+ ),
182
+ )
183
+
184
+
185
+ def read_file_history(path: str | Path, *, max_commits: int = 5) -> dict:
186
+ """Return ``{"current": str, "log": [str], "blame": [str] | None}``.
187
+
188
+ Best-effort: missing git, missing file, or non-zero exit codes are
189
+ swallowed so the guardrail itself never errors out. The dictionary is
190
+ purely informational — the agent must surface it in its reply when the
191
+ guard fires.
192
+ """
193
+ path_obj = Path(path)
194
+ try:
195
+ current = path_obj.read_text(encoding="utf-8", errors="replace")
196
+ except OSError:
197
+ current = ""
198
+
199
+ log_lines: list[str] = []
200
+ try:
201
+ completed = subprocess.run(
202
+ ["git", "log", f"-{max_commits}", "--pretty=format:%h %ad %s", "--date=short", "--", str(path_obj)],
203
+ cwd=path_obj.parent if path_obj.parent.exists() else Path.cwd(),
204
+ capture_output=True,
205
+ text=True,
206
+ timeout=4,
207
+ check=False,
208
+ )
209
+ if completed.returncode == 0 and completed.stdout.strip():
210
+ log_lines = completed.stdout.splitlines()
211
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
212
+ log_lines = []
213
+
214
+ blame_lines: list[str] | None = None
215
+ if current:
216
+ try:
217
+ completed = subprocess.run(
218
+ ["git", "blame", "--line-porcelain", "-L", "1,40", str(path_obj)],
219
+ cwd=path_obj.parent if path_obj.parent.exists() else Path.cwd(),
220
+ capture_output=True,
221
+ text=True,
222
+ timeout=4,
223
+ check=False,
224
+ )
225
+ if completed.returncode == 0:
226
+ blame_lines = completed.stdout.splitlines()
227
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
228
+ blame_lines = None
229
+
230
+ return {"current": current, "log": log_lines, "blame": blame_lines}
231
+
232
+
233
+ def evaluate(
234
+ prompt_text: str,
235
+ target_path: str | Path,
236
+ old_text: str,
237
+ new_text: str,
238
+ *,
239
+ threshold: int = DEFAULT_THRESHOLD,
240
+ ) -> dict:
241
+ """Convenience wrapper used by hook_guardrails.
242
+
243
+ Returns a payload safe to JSON-serialize and embed in the deny reason
244
+ or the pass-through note.
245
+ """
246
+ decision = check_diff(prompt_text, old_text, new_text, target_path, threshold=threshold)
247
+ payload = decision.to_payload()
248
+ payload["target_path"] = str(target_path)
249
+ if decision.decision == "block":
250
+ history = read_file_history(target_path)
251
+ payload["history_log"] = history["log"]
252
+ payload["current_excerpt"] = (history["current"][:400] + "…") if history["current"] else ""
253
+ return payload
254
+
255
+
256
+ __all__ = [
257
+ "DEFAULT_THRESHOLD",
258
+ "GuardDecision",
259
+ "PUNCTUAL_VERBS",
260
+ "UI_EXTENSIONS",
261
+ "check_diff",
262
+ "classify_request",
263
+ "count_changed_lines",
264
+ "evaluate",
265
+ "is_ui_path",
266
+ "read_file_history",
267
+ ]