nexo-brain 7.30.5 → 7.30.7

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.30.5",
3
+ "version": "7.30.7",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.30.5` is the current packaged-runtime line. Patch release over v7.30.4 - the morning briefing is now Morning preparation, with automatic relevance, changes since yesterday, next actions, relevant public context, and chat-addressable preference settings for non-technical users.
21
+ Version `7.30.7` is the current packaged-runtime line. Patch release over v7.30.6 - the Deep Sleep retention update is republished with the required release smoke contract so final closeout, npm, GitHub, and runtime verification stay aligned.
22
+
23
+ Previously in `7.30.6`: patch release over v7.30.5 - Deep Sleep now rotates its operational artifacts and logs automatically, keeping historical installs bounded without touching local-context memory.
22
24
 
23
25
  Previously in `7.30.4`: patch release over v7.30.3 - local runtime update post-sync now gives bounded Memory Fabric repair enough time to finish, and headless automations now treat `nexo_stop` as a terminal close so followup/deep-sleep runners do not reopen no-op protocol loops.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.5",
3
+ "version": "7.30.7",
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",
@@ -2275,6 +2275,21 @@ def _heal_deep_sleep_runtime(dest: Path = NEXO_HOME) -> list[str]:
2275
2275
  except OSError:
2276
2276
  pass
2277
2277
 
2278
+ # (5) Bound Deep Sleep operational artifacts for existing installs. Older
2279
+ # versions accumulated DB backups and large context dumps indefinitely; this
2280
+ # apply path runs on update so users do not have to wait for the next night.
2281
+ try:
2282
+ from deep_sleep_retention import prune_deep_sleep_runtime
2283
+
2284
+ report = prune_deep_sleep_runtime(nexo_home=dest, apply=True)
2285
+ deleted = int(report.get("deleted_count") or 0)
2286
+ rotated = int(report.get("logs_rotated") or 0)
2287
+ freed = int(report.get("deleted_bytes") or 0) + int(report.get("log_bytes_trimmed") or 0)
2288
+ if deleted or rotated:
2289
+ actions.append(f"retention:{deleted}-deleted:{rotated}-logs:{freed}-bytes")
2290
+ except Exception as exc:
2291
+ actions.append(f"retention-warning:{exc.__class__.__name__}")
2292
+
2278
2293
  return actions
2279
2294
 
2280
2295
 
@@ -0,0 +1,320 @@
1
+ """Deep Sleep runtime retention.
2
+
3
+ Keeps Deep Sleep operational artifacts bounded without touching the live memory
4
+ databases or the local-context index. The policy is intentionally conservative:
5
+ old context dumps are only deleted after their run produced a synthesis or agent
6
+ start packet, so failed or incomplete nights remain debuggable.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Iterable
18
+
19
+ DEFAULT_KEEP_DB_BACKUPS = int(os.environ.get("NEXO_DEEP_SLEEP_KEEP_DB_BACKUPS", "3") or "3")
20
+ DEFAULT_KEEP_CONTEXTS = int(os.environ.get("NEXO_DEEP_SLEEP_KEEP_CONTEXTS", "7") or "7")
21
+ DEFAULT_MAX_LOG_BYTES = int(os.environ.get("NEXO_DEEP_SLEEP_MAX_LOG_BYTES", str(1024 * 1024)) or str(1024 * 1024))
22
+ DEFAULT_RETAINED_LOG_BYTES = int(
23
+ os.environ.get("NEXO_DEEP_SLEEP_RETAINED_LOG_BYTES", str(768 * 1024)) or str(768 * 1024)
24
+ )
25
+ DEBUG_TTL_SECONDS = int(os.environ.get("NEXO_DEEP_SLEEP_DEBUG_TTL_SECONDS", str(7 * 86400)) or str(7 * 86400))
26
+
27
+ _DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})(?:[-T]?(\d{6}))?")
28
+ _DATE_CONTEXT_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}(?:[-T]?\d{6})?)-context\.txt$")
29
+
30
+
31
+ def _default_home() -> Path:
32
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
33
+
34
+
35
+ def _dedupe_existing(paths: Iterable[Path]) -> list[Path]:
36
+ seen: set[Path] = set()
37
+ result: list[Path] = []
38
+ for path in paths:
39
+ try:
40
+ key = path.resolve()
41
+ except OSError:
42
+ key = path
43
+ if key in seen or not path.exists():
44
+ continue
45
+ seen.add(key)
46
+ result.append(path)
47
+ return result
48
+
49
+
50
+ def _deep_sleep_dirs(nexo_home: Path) -> list[Path]:
51
+ return _dedupe_existing(
52
+ [
53
+ nexo_home / "runtime" / "operations" / "deep-sleep",
54
+ nexo_home / "operations" / "deep-sleep",
55
+ ]
56
+ )
57
+
58
+
59
+ def _log_dirs(nexo_home: Path) -> list[Path]:
60
+ return _dedupe_existing(
61
+ [
62
+ nexo_home / "runtime" / "logs",
63
+ nexo_home / "logs",
64
+ ]
65
+ )
66
+
67
+
68
+ def _artifact_sort_key(path: Path) -> tuple[float, str]:
69
+ match = _DATE_PREFIX_RE.match(path.name)
70
+ if match:
71
+ date_part, time_part = match.groups()
72
+ compact = date_part.replace("-", "") + (time_part or "000000")
73
+ try:
74
+ return (float(compact), path.name)
75
+ except ValueError:
76
+ pass
77
+ try:
78
+ return (path.stat().st_mtime, path.name)
79
+ except OSError:
80
+ return (0.0, path.name)
81
+
82
+
83
+ def _analyzed_marker_exists(deep_sleep_dir: Path, run_id: str) -> bool:
84
+ markers = [
85
+ deep_sleep_dir / f"{run_id}-agent-start-packet.json",
86
+ deep_sleep_dir / f"{run_id}-synthesis.json",
87
+ deep_sleep_dir / f"{run_id}-applied.json",
88
+ deep_sleep_dir / run_id / "synthesis.json",
89
+ ]
90
+ return any(path.exists() and path.stat().st_size > 0 for path in markers)
91
+
92
+
93
+ def _path_size(path: Path) -> int:
94
+ try:
95
+ if path.is_dir():
96
+ total = 0
97
+ for child in path.rglob("*"):
98
+ try:
99
+ if child.is_file() or child.is_symlink():
100
+ total += child.stat().st_size
101
+ except OSError:
102
+ continue
103
+ return total
104
+ return path.stat().st_size
105
+ except OSError:
106
+ return 0
107
+
108
+
109
+ def _delete_path(path: Path, *, apply: bool) -> tuple[bool, int]:
110
+ size = _path_size(path)
111
+ if not apply:
112
+ return True, size
113
+ try:
114
+ if path.is_dir():
115
+ shutil.rmtree(path)
116
+ else:
117
+ path.unlink()
118
+ return True, size
119
+ except FileNotFoundError:
120
+ return False, 0
121
+ except OSError:
122
+ return False, 0
123
+
124
+
125
+ def _sidecars(path: Path) -> list[Path]:
126
+ candidates = [Path(str(path) + "-wal"), Path(str(path) + "-shm")]
127
+ return [candidate for candidate in candidates if candidate.exists()]
128
+
129
+
130
+ def _record_delete(report: dict, path: Path, *, reason: str, apply: bool) -> None:
131
+ ok, size = _delete_path(path, apply=apply)
132
+ if not ok:
133
+ report["warnings"].append(f"delete-failed:{path}")
134
+ return
135
+ report["deleted_count"] += 1
136
+ report["deleted_bytes"] += size
137
+ report["deleted"].append({"path": str(path), "bytes": size, "reason": reason})
138
+
139
+
140
+ def _prune_db_backups(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
141
+ for family in ("*-backup-nexo.db", "*-backup-cognitive.db"):
142
+ backups = sorted(deep_sleep_dir.glob(family), key=_artifact_sort_key, reverse=True)
143
+ kept = backups[:keep]
144
+ report["kept"].append({"kind": family, "count": len(kept), "root": str(deep_sleep_dir)})
145
+ for backup in backups[keep:]:
146
+ _record_delete(report, backup, reason=f"old-db-backup:{family}", apply=apply)
147
+ for sidecar in _sidecars(backup):
148
+ _record_delete(report, sidecar, reason=f"old-db-backup-sidecar:{family}", apply=apply)
149
+
150
+
151
+ def _prune_contexts(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
152
+ contexts: list[tuple[str, Path]] = []
153
+ for path in deep_sleep_dir.glob("*-context.txt"):
154
+ match = _DATE_CONTEXT_RE.match(path.name)
155
+ if not match:
156
+ continue
157
+ contexts.append((match.group(1), path))
158
+
159
+ contexts.sort(key=lambda item: _artifact_sort_key(item[1]), reverse=True)
160
+ keep_run_ids = {run_id for run_id, _ in contexts[:keep]}
161
+ report["kept"].append({"kind": "context.txt", "count": min(len(contexts), keep), "root": str(deep_sleep_dir)})
162
+
163
+ for run_id, context_file in contexts[keep:]:
164
+ if run_id in keep_run_ids:
165
+ continue
166
+ if not _analyzed_marker_exists(deep_sleep_dir, run_id):
167
+ report["kept"].append({"kind": "unanalyzed-context", "path": str(context_file)})
168
+ continue
169
+ _record_delete(report, context_file, reason="old-analyzed-context", apply=apply)
170
+ date_dir = deep_sleep_dir / run_id
171
+ if date_dir.is_dir():
172
+ _record_delete(report, date_dir, reason="old-analyzed-context-dir", apply=apply)
173
+
174
+
175
+ def _prune_debug_scratch(deep_sleep_dir: Path, report: dict, *, now: float, apply: bool) -> None:
176
+ for pattern in ("debug-extract-*.txt", "debug-synthesize-*.txt"):
177
+ for path in deep_sleep_dir.glob(pattern):
178
+ try:
179
+ if now - path.stat().st_mtime <= DEBUG_TTL_SECONDS:
180
+ continue
181
+ except OSError:
182
+ continue
183
+ _record_delete(report, path, reason="old-debug-scratch", apply=apply)
184
+
185
+
186
+ def _rotate_log(path: Path, report: dict, *, max_bytes: int, retained_bytes: int, apply: bool) -> None:
187
+ try:
188
+ original_size = path.stat().st_size
189
+ except OSError:
190
+ return
191
+ if original_size <= max_bytes:
192
+ return
193
+ retained_bytes = max(1, min(retained_bytes, max_bytes))
194
+ if not apply:
195
+ report["logs_rotated"] += 1
196
+ report["log_bytes_trimmed"] += max(0, original_size - retained_bytes)
197
+ report["rotated_logs"].append({"path": str(path), "original_bytes": original_size, "dry_run": True})
198
+ return
199
+
200
+ try:
201
+ with path.open("rb") as fh:
202
+ if original_size > retained_bytes:
203
+ fh.seek(-retained_bytes, os.SEEK_END)
204
+ tail = fh.read()
205
+ newline = tail.find(b"\n")
206
+ if newline > 0:
207
+ tail = tail[newline + 1 :]
208
+ header = (
209
+ f"[rotated by NEXO Deep Sleep retention; original_bytes={original_size}; "
210
+ f"retained_bytes={len(tail)}]\n"
211
+ ).encode("utf-8")
212
+ path.write_bytes(header + tail)
213
+ new_size = path.stat().st_size
214
+ except OSError as exc:
215
+ report["warnings"].append(f"log-rotate-failed:{path}:{exc.__class__.__name__}")
216
+ return
217
+
218
+ report["logs_rotated"] += 1
219
+ report["log_bytes_trimmed"] += max(0, original_size - new_size)
220
+ report["rotated_logs"].append({"path": str(path), "original_bytes": original_size, "new_bytes": new_size})
221
+
222
+
223
+ def _rotate_logs(nexo_home: Path, report: dict, *, max_bytes: int, retained_bytes: int, apply: bool) -> None:
224
+ names = {
225
+ "deep-sleep.log",
226
+ "deep-sleep-stdout.log",
227
+ "deep-sleep-stderr.log",
228
+ "sleep-stdout.log",
229
+ "sleep-stderr.log",
230
+ "prevent-sleep-stdout.log",
231
+ "prevent-sleep-stderr.log",
232
+ }
233
+ for log_dir in _log_dirs(nexo_home):
234
+ for name in names:
235
+ path = log_dir / name
236
+ if path.is_file():
237
+ _rotate_log(path, report, max_bytes=max_bytes, retained_bytes=retained_bytes, apply=apply)
238
+
239
+
240
+ def prune_deep_sleep_runtime(
241
+ *,
242
+ nexo_home: str | Path | None = None,
243
+ apply: bool = False,
244
+ keep_db_backups: int = DEFAULT_KEEP_DB_BACKUPS,
245
+ keep_contexts: int = DEFAULT_KEEP_CONTEXTS,
246
+ max_log_bytes: int = DEFAULT_MAX_LOG_BYTES,
247
+ retained_log_bytes: int = DEFAULT_RETAINED_LOG_BYTES,
248
+ ) -> dict:
249
+ """Apply or plan Deep Sleep retention for a runtime home."""
250
+ home = Path(nexo_home).expanduser() if nexo_home is not None else _default_home()
251
+ keep_db_backups = max(1, int(keep_db_backups))
252
+ keep_contexts = max(1, int(keep_contexts))
253
+ report: dict = {
254
+ "ok": True,
255
+ "apply": bool(apply),
256
+ "nexo_home": str(home),
257
+ "roots": [],
258
+ "deleted_count": 0,
259
+ "deleted_bytes": 0,
260
+ "deleted": [],
261
+ "kept": [],
262
+ "logs_rotated": 0,
263
+ "log_bytes_trimmed": 0,
264
+ "rotated_logs": [],
265
+ "warnings": [],
266
+ }
267
+
268
+ now = time.time()
269
+ for deep_sleep_dir in _deep_sleep_dirs(home):
270
+ report["roots"].append(str(deep_sleep_dir))
271
+ _prune_db_backups(deep_sleep_dir, report, keep=keep_db_backups, apply=apply)
272
+ _prune_contexts(deep_sleep_dir, report, keep=keep_contexts, apply=apply)
273
+ _prune_debug_scratch(deep_sleep_dir, report, now=now, apply=apply)
274
+ _rotate_logs(home, report, max_bytes=max_log_bytes, retained_bytes=retained_log_bytes, apply=apply)
275
+ return report
276
+
277
+
278
+ def _print_human(report: dict) -> None:
279
+ mode = "apply" if report.get("apply") else "dry-run"
280
+ print(f"NEXO Deep Sleep retention ({mode})")
281
+ print(f" roots: {len(report.get('roots') or [])}")
282
+ print(f" deleted: {report.get('deleted_count', 0)}")
283
+ print(f" freed/planned: {report.get('deleted_bytes', 0)} bytes")
284
+ print(f" logs_rotated: {report.get('logs_rotated', 0)}")
285
+ print(f" log_bytes_trimmed: {report.get('log_bytes_trimmed', 0)}")
286
+ if report.get("warnings"):
287
+ print(" warnings:")
288
+ for warning in report["warnings"]:
289
+ print(f" - {warning}")
290
+
291
+
292
+ def main(argv: list[str] | None = None) -> int:
293
+ parser = argparse.ArgumentParser(description="NEXO Deep Sleep retention")
294
+ parser.add_argument("--nexo-home", default=None)
295
+ parser.add_argument("--apply", action="store_true", help="delete/rotate instead of dry-run")
296
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
297
+ parser.add_argument("--quiet", action="store_true", help="suppress human output")
298
+ parser.add_argument("--keep-db-backups", type=int, default=DEFAULT_KEEP_DB_BACKUPS)
299
+ parser.add_argument("--keep-contexts", type=int, default=DEFAULT_KEEP_CONTEXTS)
300
+ parser.add_argument("--max-log-bytes", type=int, default=DEFAULT_MAX_LOG_BYTES)
301
+ parser.add_argument("--retained-log-bytes", type=int, default=DEFAULT_RETAINED_LOG_BYTES)
302
+ args = parser.parse_args(argv)
303
+
304
+ report = prune_deep_sleep_runtime(
305
+ nexo_home=args.nexo_home,
306
+ apply=args.apply,
307
+ keep_db_backups=args.keep_db_backups,
308
+ keep_contexts=args.keep_contexts,
309
+ max_log_bytes=args.max_log_bytes,
310
+ retained_log_bytes=args.retained_log_bytes,
311
+ )
312
+ if args.json:
313
+ print(json.dumps(report, indent=2, ensure_ascii=False))
314
+ elif not args.quiet:
315
+ _print_human(report)
316
+ return 0 if report.get("ok") else 1
317
+
318
+
319
+ if __name__ == "__main__":
320
+ raise SystemExit(main())
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ SRC_DIR = Path(__file__).resolve().parents[2]
8
+ if str(SRC_DIR) not in sys.path:
9
+ sys.path.insert(0, str(SRC_DIR))
10
+
11
+ from deep_sleep_retention import main
12
+
13
+
14
+ if __name__ == "__main__":
15
+ raise SystemExit(main())
@@ -23,6 +23,14 @@ mkdir -p "$LOG_DIR" "$DEEP_SLEEP_DIR"
23
23
 
24
24
  log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_DIR/deep-sleep.log"; }
25
25
 
26
+ run_retention() {
27
+ if ! python3 "$SCRIPT_DIR/deep-sleep/retention.py" --apply --quiet >> "$LOG_DIR/deep-sleep.log" 2>&1; then
28
+ log "Retention warning: Deep Sleep cleanup failed"
29
+ fi
30
+ }
31
+
32
+ run_retention
33
+
26
34
  # Read watermark (last processed timestamp)
27
35
  SINCE=""
28
36
  if [ -f "$WATERMARK_FILE" ]; then
@@ -46,6 +54,7 @@ if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-context.txt" ]; then
46
54
  log "No context file generated. Skipping."
47
55
  echo "$UNTIL" > "$WATERMARK_FILE"
48
56
  log "Watermark updated to $UNTIL (no sessions to process)"
57
+ run_retention
49
58
  exit 0
50
59
  fi
51
60
 
@@ -58,6 +67,7 @@ if [ "$SESSIONS" -eq 0 ]; then
58
67
  log "No sessions found. Skipping."
59
68
  echo "$UNTIL" > "$WATERMARK_FILE"
60
69
  log "Watermark updated to $UNTIL (no sessions)"
70
+ run_retention
61
71
  exit 0
62
72
  fi
63
73
 
@@ -96,3 +106,4 @@ fi
96
106
  echo "$UNTIL" > "$WATERMARK_FILE"
97
107
  log "Watermark updated to $UNTIL"
98
108
  log "=== Deep Sleep v2 complete (run_id=$RUN_ID) ==="
109
+ run_retention
@@ -41,6 +41,7 @@ if str(NEXO_CODE) not in sys.path:
41
41
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
42
42
  from constants import AUTOMATION_SUBPROCESS_TIMEOUT
43
43
  from core_prompts import render_core_prompt
44
+ from deep_sleep_retention import prune_deep_sleep_runtime
44
45
  import paths
45
46
  try:
46
47
  from client_preferences import resolve_user_model as _resolve_user_model
@@ -261,6 +262,9 @@ def stage_a_cleanup() -> dict:
261
262
  "a5_heartbeat_trimmed": False,
262
263
  "a6_reflection_trimmed": False,
263
264
  "a7_daemon_logs_deleted": 0,
265
+ "a9_deep_sleep_deleted": 0,
266
+ "a9_deep_sleep_freed_bytes": 0,
267
+ "a9_deep_sleep_logs_rotated": 0,
264
268
  }
265
269
 
266
270
  # A1: Delete daily_summaries/*.md >90 days
@@ -366,6 +370,16 @@ def stage_a_cleanup() -> dict:
366
370
  except Exception:
367
371
  pass
368
372
 
373
+ # A9: Bound Deep Sleep operational artifacts and logs. This is safe to run
374
+ # daily: incomplete/unanalysed contexts are preserved for retry/debugging.
375
+ try:
376
+ deep_sleep_retention = prune_deep_sleep_runtime(nexo_home=NEXO_HOME, apply=True)
377
+ stats["a9_deep_sleep_deleted"] = int(deep_sleep_retention.get("deleted_count") or 0)
378
+ stats["a9_deep_sleep_freed_bytes"] = int(deep_sleep_retention.get("deleted_bytes") or 0)
379
+ stats["a9_deep_sleep_logs_rotated"] = int(deep_sleep_retention.get("logs_rotated") or 0)
380
+ except Exception as exc:
381
+ stats["a9_deep_sleep_warning"] = exc.__class__.__name__
382
+
369
383
  return stats
370
384
 
371
385