nexo-brain 5.3.20 → 5.3.21
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/package.json +1 -1
- package/src/auto_update.py +11 -8
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NEXO Sleep System v2 — The brain dreams.
|
|
4
|
+
|
|
5
|
+
Before: 834 lines with word-overlap "intelligence" for learning consolidation.
|
|
6
|
+
Now: Stage A (mechanical cleanup) stays pure Python. Stage B (dreaming) uses
|
|
7
|
+
the configured automation backend to understand, deduplicate, and prune with real intelligence.
|
|
8
|
+
|
|
9
|
+
Triggered hourly via LaunchAgent. Runs ONCE per day, first time Mac is awake.
|
|
10
|
+
If interrupted (power loss, crash), resumes on next trigger.
|
|
11
|
+
|
|
12
|
+
Stage A — Housekeeping (Python pure):
|
|
13
|
+
Delete old logs, rotate files, trim JSON. No intelligence needed.
|
|
14
|
+
|
|
15
|
+
Stage B — Dreaming (automation backend):
|
|
16
|
+
Review learnings for duplicates and contradictions with UNDERSTANDING.
|
|
17
|
+
Prune MEMORY.md if over limit. Clean preferences. Compress old observations.
|
|
18
|
+
One CLI call that does what 500 lines of word-overlap couldn't.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import fcntl
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sqlite3
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, date, timedelta
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
33
|
+
_script_dir = Path(__file__).resolve().parent
|
|
34
|
+
_repo_src = _script_dir.parent
|
|
35
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
|
|
36
|
+
if str(NEXO_CODE) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(NEXO_CODE))
|
|
38
|
+
|
|
39
|
+
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
40
|
+
try:
|
|
41
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
42
|
+
_USER_MODEL = _resolve_user_model()
|
|
43
|
+
except Exception:
|
|
44
|
+
_USER_MODEL = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── Paths ────────────────────────────────────────────────────────────────────
|
|
48
|
+
CLAUDE_DIR = NEXO_HOME
|
|
49
|
+
BRAIN_DIR = CLAUDE_DIR / "brain"
|
|
50
|
+
COORD_DIR = CLAUDE_DIR / "coordination"
|
|
51
|
+
MEMORY_DIR = CLAUDE_DIR / "memory"
|
|
52
|
+
DAEMON_LOGS_DIR = CLAUDE_DIR / "daemon" / "logs"
|
|
53
|
+
|
|
54
|
+
DAILY_SUMMARIES_DIR = BRAIN_DIR / "daily_summaries"
|
|
55
|
+
SESSION_ARCHIVE_DIR = BRAIN_DIR / "session_archive"
|
|
56
|
+
COMPRESSED_MEMORIES_DIR = BRAIN_DIR / "compressed_memories"
|
|
57
|
+
|
|
58
|
+
HEARTBEAT_LOG = COORD_DIR / "heartbeat-log.json"
|
|
59
|
+
REFLECTION_LOG = COORD_DIR / "reflection-log.json"
|
|
60
|
+
SLEEP_LOG = COORD_DIR / "sleep-log.json"
|
|
61
|
+
|
|
62
|
+
MEMORY_MD = NEXO_HOME / "memory" / "MEMORY.md"
|
|
63
|
+
NEXO_DB = NEXO_HOME / "data" / "nexo.db"
|
|
64
|
+
CLAUDE_MEM_DB = Path.home() / ".claude-mem" / "claude-mem.db"
|
|
65
|
+
def _resolve_claude_cli() -> Path:
|
|
66
|
+
"""Find claude CLI: saved path > PATH > common locations."""
|
|
67
|
+
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
68
|
+
if saved.exists():
|
|
69
|
+
p = Path(saved.read_text().strip())
|
|
70
|
+
if p.exists():
|
|
71
|
+
return p
|
|
72
|
+
found = shutil.which("claude")
|
|
73
|
+
if found:
|
|
74
|
+
return Path(found)
|
|
75
|
+
for candidate in [
|
|
76
|
+
Path.home() / ".local" / "bin" / "claude",
|
|
77
|
+
Path.home() / ".npm-global" / "bin" / "claude",
|
|
78
|
+
Path("/usr/local/bin/claude"),
|
|
79
|
+
]:
|
|
80
|
+
if candidate.exists():
|
|
81
|
+
return candidate
|
|
82
|
+
return Path.home() / ".local" / "bin" / "claude"
|
|
83
|
+
|
|
84
|
+
CLAUDE_CLI = _resolve_claude_cli()
|
|
85
|
+
|
|
86
|
+
LAST_RUN_FILE = COORD_DIR / "sleep-last-run"
|
|
87
|
+
LOCK_FILE = COORD_DIR / "sleep.lock"
|
|
88
|
+
PROCESS_LOCK = COORD_DIR / "sleep-process.lock"
|
|
89
|
+
|
|
90
|
+
TODAY = date.today()
|
|
91
|
+
NOW = datetime.now()
|
|
92
|
+
TIMESTAMP = NOW.strftime("%Y-%m-%d %H:%M")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ─── Run-once & resume logic (unchanged from v1) ──────────────────────────────
|
|
96
|
+
|
|
97
|
+
def already_ran_today() -> bool:
|
|
98
|
+
if not LAST_RUN_FILE.exists():
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
return LAST_RUN_FILE.read_text().strip() == str(TODAY)
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def was_interrupted() -> bool:
|
|
107
|
+
if not LOCK_FILE.exists():
|
|
108
|
+
return False
|
|
109
|
+
try:
|
|
110
|
+
lock_data = json.loads(LOCK_FILE.read_text())
|
|
111
|
+
if lock_data.get("date") != str(TODAY):
|
|
112
|
+
LOCK_FILE.unlink()
|
|
113
|
+
return False
|
|
114
|
+
lock_pid = lock_data.get("pid")
|
|
115
|
+
if lock_pid:
|
|
116
|
+
try:
|
|
117
|
+
os.kill(lock_pid, 0)
|
|
118
|
+
log(f"Another instance running (PID {lock_pid}). Exiting.")
|
|
119
|
+
return False
|
|
120
|
+
except ProcessLookupError:
|
|
121
|
+
log(f"Interrupted run (phase: {lock_data.get('phase', '?')}). Resuming.")
|
|
122
|
+
return True
|
|
123
|
+
except PermissionError:
|
|
124
|
+
return False
|
|
125
|
+
LOCK_FILE.unlink()
|
|
126
|
+
return False
|
|
127
|
+
except Exception:
|
|
128
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_interrupted_phase() -> str:
|
|
133
|
+
try:
|
|
134
|
+
return json.loads(LOCK_FILE.read_text()).get("phase", "stage_a")
|
|
135
|
+
except Exception:
|
|
136
|
+
return "stage_a"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_lock(phase: str):
|
|
140
|
+
save_json(LOCK_FILE, {"date": str(TODAY), "phase": phase, "started": TIMESTAMP, "pid": os.getpid()})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def mark_complete():
|
|
144
|
+
LAST_RUN_FILE.write_text(str(TODAY))
|
|
145
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def log(msg: str):
|
|
151
|
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
152
|
+
print(f"[{ts}] {msg}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_json(path: Path, default=None):
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return default if default is not None else {}
|
|
158
|
+
try:
|
|
159
|
+
return json.loads(path.read_text())
|
|
160
|
+
except Exception:
|
|
161
|
+
return default if default is not None else {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_json(path: Path, data):
|
|
165
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_date_from_stem(stem: str):
|
|
170
|
+
m = re.search(r'(\d{4}-\d{2}-\d{2})', stem)
|
|
171
|
+
if m:
|
|
172
|
+
try:
|
|
173
|
+
return date.fromisoformat(m.group(1))
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def append_sleep_log(entry: dict):
|
|
180
|
+
entries = load_json(SLEEP_LOG, [])
|
|
181
|
+
if not isinstance(entries, list):
|
|
182
|
+
entries = []
|
|
183
|
+
entries.append(entry)
|
|
184
|
+
if len(entries) > 90:
|
|
185
|
+
entries = entries[-90:]
|
|
186
|
+
save_json(SLEEP_LOG, entries)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ─── Stage A: Mechanical cleanup (UNCHANGED from v1) ─────────────────────────
|
|
190
|
+
|
|
191
|
+
def stage_a_cleanup() -> dict:
|
|
192
|
+
"""Pure Python cleanup. No LLM calls."""
|
|
193
|
+
stats = {
|
|
194
|
+
"a1_daily_summaries_deleted": 0,
|
|
195
|
+
"a2_session_archives_deleted": 0,
|
|
196
|
+
"a3_logs_rotated": 0,
|
|
197
|
+
"a4_compressed_memories_deleted": 0,
|
|
198
|
+
"a5_heartbeat_trimmed": False,
|
|
199
|
+
"a6_reflection_trimmed": False,
|
|
200
|
+
"a7_daemon_logs_deleted": 0,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# A1: Delete daily_summaries/*.md >90 days
|
|
204
|
+
cutoff_90 = TODAY - timedelta(days=90)
|
|
205
|
+
if DAILY_SUMMARIES_DIR.exists():
|
|
206
|
+
for f in DAILY_SUMMARIES_DIR.glob("*.md"):
|
|
207
|
+
d = parse_date_from_stem(f.stem)
|
|
208
|
+
if d and d < cutoff_90:
|
|
209
|
+
try:
|
|
210
|
+
f.unlink()
|
|
211
|
+
stats["a1_daily_summaries_deleted"] += 1
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
# A2: Delete session_archive/*.jsonl >30 days
|
|
216
|
+
cutoff_30 = TODAY - timedelta(days=30)
|
|
217
|
+
if SESSION_ARCHIVE_DIR.exists():
|
|
218
|
+
for f in SESSION_ARCHIVE_DIR.glob("*.jsonl"):
|
|
219
|
+
d = parse_date_from_stem(f.stem)
|
|
220
|
+
if d and d < cutoff_30:
|
|
221
|
+
try:
|
|
222
|
+
f.unlink()
|
|
223
|
+
stats["a2_session_archives_deleted"] += 1
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
# A3: Rotate coordination/*-stdout.log if >5MB
|
|
228
|
+
if COORD_DIR.exists():
|
|
229
|
+
for f in COORD_DIR.glob("*-stdout.log"):
|
|
230
|
+
try:
|
|
231
|
+
if f.stat().st_size > 5 * 1024 * 1024:
|
|
232
|
+
lines = f.read_text().splitlines()
|
|
233
|
+
keep = lines[-500:]
|
|
234
|
+
f.write_text("\n".join(keep) + "\n")
|
|
235
|
+
stats["a3_logs_rotated"] += 1
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# A4: Delete compressed_memories/week_*.md >180 days
|
|
240
|
+
cutoff_180 = TODAY - timedelta(days=180)
|
|
241
|
+
if COMPRESSED_MEMORIES_DIR.exists():
|
|
242
|
+
for f in COMPRESSED_MEMORIES_DIR.glob("week_*.md"):
|
|
243
|
+
d = parse_date_from_stem(f.stem)
|
|
244
|
+
if d and d < cutoff_180:
|
|
245
|
+
try:
|
|
246
|
+
f.unlink()
|
|
247
|
+
stats["a4_compressed_memories_deleted"] += 1
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
# A5: Trim heartbeat-log.json to 200 entries
|
|
252
|
+
if HEARTBEAT_LOG.exists():
|
|
253
|
+
try:
|
|
254
|
+
data = load_json(HEARTBEAT_LOG, [])
|
|
255
|
+
if isinstance(data, list) and len(data) > 200:
|
|
256
|
+
save_json(HEARTBEAT_LOG, data[-200:])
|
|
257
|
+
stats["a5_heartbeat_trimmed"] = True
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
# A6: Trim reflection-log.json to 60 entries
|
|
262
|
+
if REFLECTION_LOG.exists():
|
|
263
|
+
try:
|
|
264
|
+
data = load_json(REFLECTION_LOG, [])
|
|
265
|
+
if isinstance(data, list) and len(data) > 60:
|
|
266
|
+
save_json(REFLECTION_LOG, data[-60:])
|
|
267
|
+
stats["a6_reflection_trimmed"] = True
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# A7: Delete daemon/logs/ dirs >14 days
|
|
272
|
+
cutoff_14 = TODAY - timedelta(days=14)
|
|
273
|
+
if DAEMON_LOGS_DIR.exists():
|
|
274
|
+
for d_path in sorted(DAEMON_LOGS_DIR.iterdir()):
|
|
275
|
+
if not d_path.is_dir():
|
|
276
|
+
continue
|
|
277
|
+
d = parse_date_from_stem(d_path.name)
|
|
278
|
+
if d and d < cutoff_14:
|
|
279
|
+
try:
|
|
280
|
+
shutil.rmtree(d_path)
|
|
281
|
+
stats["a7_daemon_logs_deleted"] += 1
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
# A8: Delete cortex/logs/*.log >7 days, truncate launchd >5MB
|
|
286
|
+
cutoff_7 = TODAY - timedelta(days=7)
|
|
287
|
+
cortex_logs = NEXO_HOME / "cortex" / "logs"
|
|
288
|
+
if cortex_logs.exists():
|
|
289
|
+
for f in cortex_logs.glob("*.log"):
|
|
290
|
+
if f.name.startswith("launchd-"):
|
|
291
|
+
try:
|
|
292
|
+
if f.stat().st_size > 5 * 1024 * 1024:
|
|
293
|
+
lines = f.read_text().splitlines()
|
|
294
|
+
f.write_text("\n".join(lines[-500:]) + "\n")
|
|
295
|
+
stats["a3_logs_rotated"] += 1
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
continue
|
|
299
|
+
d = parse_date_from_stem(f.stem)
|
|
300
|
+
if d and d < cutoff_7:
|
|
301
|
+
try:
|
|
302
|
+
f.unlink()
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
return stats
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ─── Stage B: Dreaming (automation backend) ─────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def collect_brain_state() -> dict:
|
|
312
|
+
"""Collect all data the CLI needs to dream."""
|
|
313
|
+
state = {"learnings": [], "preferences": [], "memory_md_lines": 0,
|
|
314
|
+
"claude_mem_old": 0, "feedback_count": 0}
|
|
315
|
+
|
|
316
|
+
if NEXO_DB.exists():
|
|
317
|
+
try:
|
|
318
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
319
|
+
conn.row_factory = sqlite3.Row
|
|
320
|
+
|
|
321
|
+
# Learnings
|
|
322
|
+
rows = conn.execute(
|
|
323
|
+
"SELECT id, title, content, category, created_at FROM learnings "
|
|
324
|
+
"WHERE status='active' ORDER BY id"
|
|
325
|
+
).fetchall()
|
|
326
|
+
state["learnings"] = [dict(r) for r in rows]
|
|
327
|
+
|
|
328
|
+
# Preferences
|
|
329
|
+
rows = conn.execute("SELECT key, value, category, updated_at FROM preferences").fetchall()
|
|
330
|
+
state["preferences"] = [dict(r) for r in rows]
|
|
331
|
+
|
|
332
|
+
conn.close()
|
|
333
|
+
except Exception as e:
|
|
334
|
+
log(f"DB error: {e}")
|
|
335
|
+
|
|
336
|
+
# MEMORY.md
|
|
337
|
+
if MEMORY_MD.exists():
|
|
338
|
+
state["memory_md_lines"] = len(MEMORY_MD.read_text().splitlines())
|
|
339
|
+
|
|
340
|
+
# claude-mem.db old observations
|
|
341
|
+
if CLAUDE_MEM_DB.exists():
|
|
342
|
+
try:
|
|
343
|
+
cutoff = int((datetime.now() - timedelta(days=60)).timestamp() * 1000)
|
|
344
|
+
conn = sqlite3.connect(str(CLAUDE_MEM_DB))
|
|
345
|
+
state["claude_mem_old"] = conn.execute(
|
|
346
|
+
"SELECT COUNT(*) FROM observations WHERE created_at_epoch < ?", (cutoff,)
|
|
347
|
+
).fetchone()[0]
|
|
348
|
+
conn.close()
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
# Feedback count
|
|
353
|
+
state["feedback_count"] = len(list(MEMORY_MD.parent.glob("feedback_*.md")))
|
|
354
|
+
|
|
355
|
+
return state
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def should_dream(state: dict) -> bool:
|
|
359
|
+
"""Check if there's enough to justify a CLI call."""
|
|
360
|
+
return (
|
|
361
|
+
len(state["learnings"]) > 10
|
|
362
|
+
or state["memory_md_lines"] > 170
|
|
363
|
+
or len(state["preferences"]) > 5
|
|
364
|
+
or state["claude_mem_old"] > 500
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def dream(state: dict) -> dict:
|
|
369
|
+
"""The brain dreams — CLI does the intelligent work."""
|
|
370
|
+
|
|
371
|
+
# Truncate learnings JSON if too large
|
|
372
|
+
learnings_json = json.dumps(state["learnings"], ensure_ascii=False, indent=1)
|
|
373
|
+
if len(learnings_json) > 15000:
|
|
374
|
+
learnings_json = learnings_json[:15000] + "\n... (truncated)"
|
|
375
|
+
|
|
376
|
+
tasks = []
|
|
377
|
+
|
|
378
|
+
tasks.append(f"""TASK 1: LEARNING CONSOLIDATION ({len(state['learnings'])} active)
|
|
379
|
+
Review these learnings and identify:
|
|
380
|
+
a) DUPLICATES: learnings that say the same thing differently.
|
|
381
|
+
b) CONTRADICTIONS: learnings that contradict each other.
|
|
382
|
+
c) STALE: learnings about bugs/issues fixed >60 days ago that are never referenced.
|
|
383
|
+
|
|
384
|
+
Write your findings to {COORD_DIR}/sleep-report.md with sections:
|
|
385
|
+
- "## Duplicates to archive" — list learning IDs to archive and why
|
|
386
|
+
- "## Contradictions" — pairs of conflicting learnings
|
|
387
|
+
- "## Stale candidates" — IDs of learnings that may be obsolete
|
|
388
|
+
|
|
389
|
+
Also write a machine-readable file {COORD_DIR}/sleep-actions.json:
|
|
390
|
+
{{"archive_ids": [1, 2, 3], "contradiction_pairs": [[4, 5]], "stale_ids": [6, 7]}}
|
|
391
|
+
|
|
392
|
+
The wrapper will execute the actual DB operations based on this JSON.
|
|
393
|
+
|
|
394
|
+
LEARNINGS:
|
|
395
|
+
{learnings_json}""")
|
|
396
|
+
|
|
397
|
+
if state["memory_md_lines"] > 170:
|
|
398
|
+
tasks.append(f"""TASK 2: MEMORY.MD COMPRESSION ({state['memory_md_lines']} lines, limit 200)
|
|
399
|
+
File: {MEMORY_MD}
|
|
400
|
+
Read it, compress resolved incidents >21 days, merge duplicates.
|
|
401
|
+
NEVER delete: credentials, legal entity info, CRITICAL rules, infrastructure.
|
|
402
|
+
Target: <180 lines.""")
|
|
403
|
+
|
|
404
|
+
if len(state["preferences"]) > 5:
|
|
405
|
+
tasks.append(f"""TASK 3: PREFERENCES CLEANUP ({len(state['preferences'])} entries)
|
|
406
|
+
Review the preferences and identify duplicate keys.
|
|
407
|
+
Add to sleep-actions.json: "duplicate_preference_keys": ["key1", "key2", ...]
|
|
408
|
+
The wrapper will handle the actual DB cleanup safely.""")
|
|
409
|
+
|
|
410
|
+
if state["claude_mem_old"] > 500:
|
|
411
|
+
tasks.append(f"""TASK 4: OLD OBSERVATIONS ({state['claude_mem_old']} entries >60d)
|
|
412
|
+
Note in sleep-report.md that old observations should be cleaned.
|
|
413
|
+
Add to sleep-actions.json: "clean_old_observations": true
|
|
414
|
+
The wrapper will handle the actual DB cleanup safely.""")
|
|
415
|
+
|
|
416
|
+
tasks_str = "\n\n".join(tasks)
|
|
417
|
+
|
|
418
|
+
prompt = f"""FIRST: Call nexo_startup(task='deep-sleep nightly maintenance') to register this session.
|
|
419
|
+
|
|
420
|
+
You are NEXO Sleep — the nightly brain maintenance process.
|
|
421
|
+
Like a human brain during sleep: consolidate important memories, discard noise,
|
|
422
|
+
detect conflicts, prepare state for tomorrow.
|
|
423
|
+
Use nexo_learning_add, nexo_followup_create, nexo_session_diary_write and other MCP tools directly.
|
|
424
|
+
|
|
425
|
+
BRAIN STATE:
|
|
426
|
+
- {len(state['learnings'])} active learnings
|
|
427
|
+
- {state['memory_md_lines']} lines in MEMORY.md (limit: 200)
|
|
428
|
+
- {len(state['preferences'])} preferences
|
|
429
|
+
- {state['feedback_count']} feedback files
|
|
430
|
+
- {state['claude_mem_old']} old observations (>60d)
|
|
431
|
+
|
|
432
|
+
{tasks_str}
|
|
433
|
+
|
|
434
|
+
ABSOLUTE RULES:
|
|
435
|
+
- NEVER delete legal entity info (LLC, SLU, EIN, NIF, project)
|
|
436
|
+
- NEVER delete credentials, tokens, API keys, secrets
|
|
437
|
+
- NEVER delete rules marked CRITICAL or MAX PRIORITY
|
|
438
|
+
- NEVER delete infrastructure info (servers, repos, deploys)
|
|
439
|
+
- When in doubt, DON'T delete
|
|
440
|
+
|
|
441
|
+
Write a summary to {COORD_DIR}/sleep-report.md when done.
|
|
442
|
+
Execute without asking."""
|
|
443
|
+
|
|
444
|
+
log("Stage B: Invoking automation backend — dreaming...")
|
|
445
|
+
try:
|
|
446
|
+
result = run_automation_prompt(
|
|
447
|
+
prompt,
|
|
448
|
+
model=_USER_MODEL or "opus",
|
|
449
|
+
timeout=21600,
|
|
450
|
+
output_format="text",
|
|
451
|
+
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if result.returncode != 0:
|
|
455
|
+
log(f"Stage B: CLI error ({result.returncode}): {(result.stderr or '')[:300]}")
|
|
456
|
+
return {"error": result.returncode}
|
|
457
|
+
|
|
458
|
+
log(f"Stage B: Dreaming complete. Output: {len(result.stdout or '')} chars")
|
|
459
|
+
return {"ok": True, "output_len": len(result.stdout or "")}
|
|
460
|
+
|
|
461
|
+
except AutomationBackendUnavailableError as e:
|
|
462
|
+
log(f"Stage B: automation backend unavailable: {e}")
|
|
463
|
+
return {"error": "backend-unavailable"}
|
|
464
|
+
except subprocess.TimeoutExpired:
|
|
465
|
+
log("Stage B: CLI timed out (600s)")
|
|
466
|
+
return {"error": "timeout"}
|
|
467
|
+
except Exception as e:
|
|
468
|
+
log(f"Stage B: Exception: {e}")
|
|
469
|
+
return {"error": str(e)}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def execute_dream_actions(actions: dict, state: dict):
|
|
473
|
+
"""Execute the DB actions decided by CLI, safely in Python."""
|
|
474
|
+
log("Stage B2: Executing dream actions...")
|
|
475
|
+
|
|
476
|
+
# Archive duplicate/stale learnings
|
|
477
|
+
archive_ids = actions.get("archive_ids", []) + actions.get("stale_ids", [])
|
|
478
|
+
if archive_ids and NEXO_DB.exists():
|
|
479
|
+
try:
|
|
480
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
481
|
+
for lid in archive_ids:
|
|
482
|
+
if isinstance(lid, int):
|
|
483
|
+
conn.execute(
|
|
484
|
+
"UPDATE learnings SET status='archived' WHERE id=? AND status='active'",
|
|
485
|
+
(lid,)
|
|
486
|
+
)
|
|
487
|
+
conn.commit()
|
|
488
|
+
conn.close()
|
|
489
|
+
log(f" Archived {len(archive_ids)} learnings: {archive_ids}")
|
|
490
|
+
except Exception as e:
|
|
491
|
+
log(f" Error archiving learnings: {e}")
|
|
492
|
+
|
|
493
|
+
# Clean duplicate preferences
|
|
494
|
+
dup_keys = actions.get("duplicate_preference_keys", [])
|
|
495
|
+
if dup_keys and NEXO_DB.exists():
|
|
496
|
+
try:
|
|
497
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
498
|
+
for key in dup_keys:
|
|
499
|
+
if isinstance(key, str):
|
|
500
|
+
# Keep newest, delete older duplicates
|
|
501
|
+
conn.execute(
|
|
502
|
+
"DELETE FROM preferences WHERE key = ? AND rowid NOT IN "
|
|
503
|
+
"(SELECT rowid FROM preferences WHERE key = ? ORDER BY updated_at DESC LIMIT 1)",
|
|
504
|
+
(key, key)
|
|
505
|
+
)
|
|
506
|
+
conn.commit()
|
|
507
|
+
conn.close()
|
|
508
|
+
log(f" Cleaned {len(dup_keys)} duplicate preference keys")
|
|
509
|
+
except Exception as e:
|
|
510
|
+
log(f" Error cleaning preferences: {e}")
|
|
511
|
+
|
|
512
|
+
# Clean old observations
|
|
513
|
+
if actions.get("clean_old_observations") and CLAUDE_MEM_DB.exists():
|
|
514
|
+
try:
|
|
515
|
+
cutoff_ms = int((datetime.now() - timedelta(days=60)).timestamp() * 1000)
|
|
516
|
+
conn = sqlite3.connect(str(CLAUDE_MEM_DB))
|
|
517
|
+
deleted = conn.execute(
|
|
518
|
+
"DELETE FROM observations WHERE created_at_epoch < ? "
|
|
519
|
+
"AND discovery_tokens < 300 "
|
|
520
|
+
"AND id NOT IN (SELECT id FROM observations WHERE "
|
|
521
|
+
"title LIKE '%CRITICO%' OR title LIKE '%credential%' "
|
|
522
|
+
"OR title LIKE '%token%' OR title LIKE '%API%' "
|
|
523
|
+
"OR title LIKE '%LLC%' OR title LIKE '%SLU%') "
|
|
524
|
+
"LIMIT 200",
|
|
525
|
+
(cutoff_ms,)
|
|
526
|
+
).rowcount
|
|
527
|
+
conn.execute(
|
|
528
|
+
"DELETE FROM observations_fts WHERE rowid NOT IN "
|
|
529
|
+
"(SELECT id FROM observations)"
|
|
530
|
+
)
|
|
531
|
+
conn.execute("VACUUM")
|
|
532
|
+
conn.commit()
|
|
533
|
+
conn.close()
|
|
534
|
+
log(f" Cleaned {deleted} old observations")
|
|
535
|
+
except Exception as e:
|
|
536
|
+
log(f" Error cleaning observations: {e}")
|
|
537
|
+
|
|
538
|
+
log("Stage B2: Actions complete.")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
def main():
|
|
544
|
+
log("=" * 60)
|
|
545
|
+
log("NEXO Sleep System v2 starting")
|
|
546
|
+
|
|
547
|
+
# Process lock
|
|
548
|
+
try:
|
|
549
|
+
lock_fd = open(PROCESS_LOCK, "w")
|
|
550
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
551
|
+
lock_fd.write(str(os.getpid()))
|
|
552
|
+
lock_fd.flush()
|
|
553
|
+
except (IOError, OSError):
|
|
554
|
+
log("Another sleep instance running. Exiting.")
|
|
555
|
+
sys.exit(0)
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
if already_ran_today():
|
|
559
|
+
log("Already ran today. Exiting.")
|
|
560
|
+
sys.exit(0)
|
|
561
|
+
|
|
562
|
+
start_phase = "stage_a"
|
|
563
|
+
if was_interrupted():
|
|
564
|
+
start_phase = get_interrupted_phase()
|
|
565
|
+
|
|
566
|
+
run_log = {"date": str(TODAY), "started": TIMESTAMP,
|
|
567
|
+
"stage_a": None, "stage_b": None, "completed": None}
|
|
568
|
+
sleep_had_errors = False
|
|
569
|
+
|
|
570
|
+
# Stage A: Housekeeping (mechanical)
|
|
571
|
+
if start_phase == "stage_a":
|
|
572
|
+
set_lock("stage_a")
|
|
573
|
+
log("─── Stage A: Housekeeping ───")
|
|
574
|
+
run_log["stage_a"] = stage_a_cleanup()
|
|
575
|
+
|
|
576
|
+
# Stage B: Dreaming (intelligent)
|
|
577
|
+
set_lock("stage_b")
|
|
578
|
+
log("─── Stage B: Dreaming ───")
|
|
579
|
+
state = collect_brain_state()
|
|
580
|
+
|
|
581
|
+
if should_dream(state):
|
|
582
|
+
log(f"Brain state: {len(state['learnings'])} learnings, "
|
|
583
|
+
f"{state['memory_md_lines']} MEMORY lines, "
|
|
584
|
+
f"{state['claude_mem_old']} old observations")
|
|
585
|
+
dream_result = dream(state)
|
|
586
|
+
run_log["stage_b"] = dream_result
|
|
587
|
+
|
|
588
|
+
if "error" in dream_result:
|
|
589
|
+
log(f"Stage B: Dreaming failed ({dream_result['error']}). "
|
|
590
|
+
"Stage A cleanup completed successfully. Not marking catchup to allow retry.")
|
|
591
|
+
sleep_had_errors = True
|
|
592
|
+
else:
|
|
593
|
+
# Stage B2: Execute actions from CLI output
|
|
594
|
+
actions_file = COORD_DIR / "sleep-actions.json"
|
|
595
|
+
if actions_file.exists():
|
|
596
|
+
try:
|
|
597
|
+
actions = json.loads(actions_file.read_text())
|
|
598
|
+
execute_dream_actions(actions, state)
|
|
599
|
+
except Exception as e:
|
|
600
|
+
log(f"Stage B2: Error executing actions: {e}")
|
|
601
|
+
else:
|
|
602
|
+
log("Brain is clean -- no dreaming needed.")
|
|
603
|
+
run_log["stage_b"] = {"skipped": True}
|
|
604
|
+
|
|
605
|
+
# Done
|
|
606
|
+
run_log["completed"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
607
|
+
mark_complete()
|
|
608
|
+
append_sleep_log(run_log)
|
|
609
|
+
log(f"NEXO Sleep v2 complete at {run_log['completed']}")
|
|
610
|
+
|
|
611
|
+
# Register for catch-up only if all stages succeeded
|
|
612
|
+
if not sleep_had_errors:
|
|
613
|
+
try:
|
|
614
|
+
state_file = NEXO_HOME / "operations" / ".catchup-state.json"
|
|
615
|
+
st = json.loads(state_file.read_text()) if state_file.exists() else {}
|
|
616
|
+
st["sleep"] = datetime.now().isoformat()
|
|
617
|
+
state_file.write_text(json.dumps(st, indent=2))
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
finally:
|
|
622
|
+
try:
|
|
623
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
624
|
+
lock_fd.close()
|
|
625
|
+
PROCESS_LOCK.unlink(missing_ok=True)
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
if __name__ == "__main__":
|
|
631
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# NEXO Snapshot Restore — restores files from a snapshot directory.
|
|
3
|
+
# Usage: nexo-snapshot-restore.sh <snapshot-dir>
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SNAP_DIR="${1:?Usage: nexo-snapshot-restore.sh <snapshot-dir>}"
|
|
7
|
+
MANIFEST="$SNAP_DIR/manifest.json"
|
|
8
|
+
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
9
|
+
RESTORE_LOG="$NEXO_HOME/logs/snapshot-restores.log"
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$MANIFEST" ]; then
|
|
12
|
+
echo "ERROR: No manifest.json in $SNAP_DIR" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
TS=$(date "+%Y-%m-%d %H:%M:%S")
|
|
17
|
+
mkdir -p "$(dirname "$RESTORE_LOG")"
|
|
18
|
+
echo "[$TS] Restoring from $SNAP_DIR" >> "$RESTORE_LOG"
|
|
19
|
+
|
|
20
|
+
python3 -c "
|
|
21
|
+
import json, shutil, os
|
|
22
|
+
manifest = json.load(open('$MANIFEST'))
|
|
23
|
+
for rel_path in manifest.get('files', []):
|
|
24
|
+
src = os.path.join('$SNAP_DIR', 'files', rel_path)
|
|
25
|
+
dst = os.path.expanduser('~/' + rel_path)
|
|
26
|
+
if os.path.exists(src):
|
|
27
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
28
|
+
shutil.copy2(src, dst)
|
|
29
|
+
print(f' Restored: {rel_path}')
|
|
30
|
+
else:
|
|
31
|
+
print(f' SKIP (not in snapshot): {rel_path}')
|
|
32
|
+
print('Restore complete.')
|
|
33
|
+
"
|
|
34
|
+
|
|
35
|
+
echo "[$TS] Restore complete from $SNAP_DIR" >> "$RESTORE_LOG"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
if str(ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(ROOT))
|
|
11
|
+
|
|
12
|
+
from client_sync import main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
raise SystemExit(main())
|