nexo-brain 7.1.1 → 7.1.2
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 +3 -2
- package/bin/nexo-brain.js +198 -92
- package/package.json +1 -1
- package/src/agent_runner.py +10 -8
- package/src/auto_close_sessions.py +19 -2
- package/src/auto_update.py +232 -28
- package/src/autonomy_mandate.py +260 -0
- package/src/bootstrap_docs.py +22 -1
- package/src/cli.py +181 -1
- package/src/cli_email.py +104 -73
- package/src/client_sync.py +22 -1
- package/src/cognitive/_core.py +5 -3
- package/src/core_prompts.py +50 -0
- package/src/cron_recovery.py +81 -7
- package/src/crons/manifest.json +57 -0
- package/src/crons/sync.py +95 -26
- package/src/dashboard/app.py +59 -0
- package/src/dashboard/templates/base.html +2 -0
- package/src/dashboard/templates/feature-disabled.html +27 -0
- package/src/db/_email_accounts.py +67 -18
- package/src/db/_fts.py +5 -5
- package/src/db/_personal_scripts.py +1 -1
- package/src/db/_skills.py +3 -3
- package/src/doctor/providers/runtime.py +35 -20
- package/src/email_config.py +18 -9
- package/src/enforcement_classifier.py +3 -12
- package/src/evolution_cycle.py +37 -149
- package/src/guardian_telemetry.py +3 -2
- package/src/hook_guardrails.py +61 -0
- package/src/hooks/capture-tool-logs.sh +11 -3
- package/src/hooks/daily-briefing-check.sh +7 -2
- package/src/hooks/heartbeat-enforcement.py +14 -1
- package/src/hooks/heartbeat-posttool.sh +2 -0
- package/src/hooks/heartbeat-user-msg.sh +2 -0
- package/src/hooks/inbox-hook.sh +6 -2
- package/src/hooks/post-compact.sh +12 -4
- package/src/hooks/pre-compact.sh +12 -4
- package/src/migrate_embeddings.py +5 -3
- package/src/nexo_migrate.py +3 -1
- package/src/plugin_loader.py +14 -5
- package/src/plugins/adaptive_mode.py +4 -1
- package/src/plugins/backup.py +32 -20
- package/src/plugins/evolution.py +2 -0
- package/src/plugins/memory_export.py +6 -1
- package/src/plugins/personal_plugins.py +17 -7
- package/src/plugins/personal_scripts.py +64 -3
- package/src/presets/entities_universal.json +67 -4
- package/src/product_mode.py +201 -0
- package/src/r14_correction_learning.py +5 -20
- package/src/r15_project_context.py +4 -10
- package/src/r16_declared_done.py +3 -16
- package/src/r17_promise_debt.py +3 -16
- package/src/r18_followup_autocomplete.py +5 -7
- package/src/r19_project_grep.py +5 -8
- package/src/r20_constant_change.py +5 -15
- package/src/r21_legacy_path.py +5 -7
- package/src/r22_personal_script.py +4 -8
- package/src/r23_ssh_without_atlas.py +4 -11
- package/src/r23b_deploy_vhost.py +7 -6
- package/src/r23c_cwd_mismatch.py +7 -6
- package/src/r23d_chown_chmod_recursive.py +6 -6
- package/src/r23e_force_push_main.py +5 -6
- package/src/r23f_db_no_where.py +5 -6
- package/src/r23g_secrets_in_output.py +5 -5
- package/src/r23h_shebang_mismatch.py +6 -5
- package/src/r23i_auto_deploy_ignored.py +5 -6
- package/src/r23j_global_install.py +5 -6
- package/src/r23k_script_duplicates_skill.py +7 -6
- package/src/r23l_resource_collision.py +7 -6
- package/src/r23m_message_duplicate.py +6 -5
- package/src/r24_stale_memory.py +4 -9
- package/src/r25_nora_maria_read_only.py +5 -10
- package/src/r34_identity_coherence.py +6 -13
- package/src/r_catalog.py +3 -7
- package/src/resonance_map.py +13 -13
- package/src/runtime_power.py +29 -80
- package/src/script_registry.py +236 -6
- package/src/scripts/check-context.py +8 -25
- package/src/scripts/deep-sleep/extract.py +6 -10
- package/src/scripts/nexo-auto-update.py +27 -4
- package/src/scripts/nexo-catchup.py +9 -19
- package/src/scripts/nexo-cognitive-decay.py +26 -3
- package/src/scripts/nexo-daily-self-audit.py +50 -51
- package/src/scripts/nexo-email-migrate-config.py +30 -11
- package/src/scripts/nexo-email-monitor.py +97 -238
- package/src/scripts/nexo-followup-runner.py +70 -133
- package/src/scripts/nexo-hook-record.py +1 -1
- package/src/scripts/nexo-immune.py +6 -31
- package/src/scripts/nexo-impact-scorer.py +27 -4
- package/src/scripts/nexo-learning-housekeep.py +26 -3
- package/src/scripts/nexo-learning-validator.py +34 -32
- package/src/scripts/nexo-migrate.py +28 -12
- package/src/scripts/nexo-morning-agent.py +9 -23
- package/src/scripts/nexo-outcome-checker.py +27 -4
- package/src/scripts/nexo-postmortem-consolidator.py +30 -62
- package/src/scripts/nexo-pre-commit.py +28 -0
- package/src/scripts/nexo-proactive-dashboard.py +27 -0
- package/src/scripts/nexo-reflection.py +33 -3
- package/src/scripts/nexo-runtime-preflight.py +27 -2
- package/src/scripts/nexo-send-reply.py +10 -8
- package/src/scripts/nexo-sleep.py +11 -25
- package/src/scripts/nexo-synthesis.py +7 -40
- package/src/scripts/nexo-watchdog-smoke.py +30 -1
- package/src/scripts/nexo-watchdog.sh +23 -17
- package/src/scripts/phase_guardian_analysis.py +27 -4
- package/src/server.py +14 -3
- package/src/storage_router.py +8 -6
- package/src/tools_drive.py +5 -13
- package/src/tools_guardian.py +3 -4
- package/src/tools_menu.py +2 -2
- package/src/tools_reminders_crud.py +17 -0
- package/src/tools_sessions.py +1 -4
- package/src/user_context.py +3 -6
- package/src/user_data_portability.py +31 -23
- package/templates/CLAUDE.md.template +11 -3
- package/templates/CODEX.AGENTS.md.template +11 -3
- package/templates/core-prompts/catchup-assessment.md +19 -0
- package/templates/core-prompts/check-context.md +24 -0
- package/templates/core-prompts/daily-self-audit.md +42 -0
- package/templates/core-prompts/daily-synthesis.md +40 -0
- package/templates/core-prompts/deep-sleep-extract-json-output.md +8 -0
- package/templates/core-prompts/drive-signal-classifier-system.md +4 -0
- package/templates/core-prompts/drive-signal-classifier-user.md +6 -0
- package/templates/core-prompts/email-monitor.md +202 -0
- package/templates/core-prompts/enforcement-classifier-retry.md +1 -0
- package/templates/core-prompts/enforcement-classifier-strict.md +1 -0
- package/templates/core-prompts/evolution-public-contribution.md +32 -0
- package/templates/core-prompts/evolution-public-pr-review.md +38 -0
- package/templates/core-prompts/evolution-weekly.md +71 -0
- package/templates/core-prompts/followup-runner-operator-attention-context.md +4 -0
- package/templates/core-prompts/followup-runner-operator-attention-question.md +1 -0
- package/templates/core-prompts/followup-runner.md +74 -0
- package/templates/core-prompts/immune-triage.md +31 -0
- package/templates/core-prompts/interactive-startup.md +1 -0
- package/templates/core-prompts/json-object-only.md +1 -0
- package/templates/core-prompts/learning-validator.md +25 -0
- package/templates/core-prompts/morning-agent-json-output.md +1 -0
- package/templates/core-prompts/morning-agent.md +23 -0
- package/templates/core-prompts/postmortem-consolidator.md +60 -0
- package/templates/core-prompts/r-catalog.md +1 -0
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -0
- package/templates/core-prompts/r14-correction-learning-question.md +1 -0
- package/templates/core-prompts/r15-project-context-injection.md +1 -0
- package/templates/core-prompts/r16-declared-done-injection.md +1 -0
- package/templates/core-prompts/r16-declared-done-question.md +1 -0
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -0
- package/templates/core-prompts/r17-promise-debt-question.md +1 -0
- package/templates/core-prompts/r18-followup-autocomplete-injection.md +3 -0
- package/templates/core-prompts/r19-project-grep-injection.md +1 -0
- package/templates/core-prompts/r20-constant-change-injection.md +1 -0
- package/templates/core-prompts/r20-constant-change-question.md +1 -0
- package/templates/core-prompts/r21-legacy-path-injection.md +1 -0
- package/templates/core-prompts/r22-personal-script-injection.md +1 -0
- package/templates/core-prompts/r23-ssh-without-atlas-injection.md +1 -0
- package/templates/core-prompts/r23b-deploy-vhost-injection.md +1 -0
- package/templates/core-prompts/r23c-cwd-mismatch-injection.md +1 -0
- package/templates/core-prompts/r23d-chown-chmod-recursive-injection.md +1 -0
- package/templates/core-prompts/r23e-force-push-main-injection.md +1 -0
- package/templates/core-prompts/r23f-db-no-where-injection.md +1 -0
- package/templates/core-prompts/r23g-secrets-in-output-injection.md +1 -0
- package/templates/core-prompts/r23h-shebang-mismatch-injection.md +1 -0
- package/templates/core-prompts/r23i-auto-deploy-ignored-injection.md +1 -0
- package/templates/core-prompts/r23j-global-install-injection.md +1 -0
- package/templates/core-prompts/r23k-script-duplicates-skill-injection.md +1 -0
- package/templates/core-prompts/r23l-resource-collision-injection.md +1 -0
- package/templates/core-prompts/r23m-message-duplicate-injection.md +1 -0
- package/templates/core-prompts/r24-stale-memory-injection.md +1 -0
- package/templates/core-prompts/r25-read-only-host-injection.md +1 -0
- package/templates/core-prompts/r34-identity-coherence-probe.md +1 -0
- package/templates/core-prompts/r34-identity-coherence-question.md +1 -0
- package/templates/core-prompts/sleep.md +25 -0
- package/templates/email-template.md +55 -0
- package/templates/nexo_helper.py +31 -13
- package/templates/plugin-template.py +3 -3
- package/templates/skill-template.md +2 -1
package/src/crons/manifest.json
CHANGED
|
@@ -90,6 +90,19 @@
|
|
|
90
90
|
"run_on_boot": true,
|
|
91
91
|
"run_on_wake": true
|
|
92
92
|
},
|
|
93
|
+
{
|
|
94
|
+
"id": "backup",
|
|
95
|
+
"script": "scripts/nexo-backup.sh",
|
|
96
|
+
"type": "shell",
|
|
97
|
+
"interval_seconds": 3600,
|
|
98
|
+
"description": "Hourly validated runtime backups",
|
|
99
|
+
"core": true,
|
|
100
|
+
"recovery_policy": "restart",
|
|
101
|
+
"idempotent": true,
|
|
102
|
+
"max_catchup_age": 14400,
|
|
103
|
+
"run_on_boot": true,
|
|
104
|
+
"run_on_wake": false
|
|
105
|
+
},
|
|
93
106
|
{
|
|
94
107
|
"id": "self-audit",
|
|
95
108
|
"script": "scripts/nexo-daily-self-audit.py",
|
|
@@ -204,6 +217,50 @@
|
|
|
204
217
|
"run_on_boot": true,
|
|
205
218
|
"run_on_wake": true
|
|
206
219
|
},
|
|
220
|
+
{
|
|
221
|
+
"id": "tcc-approve",
|
|
222
|
+
"script": "scripts/nexo-tcc-approve.sh",
|
|
223
|
+
"type": "shell",
|
|
224
|
+
"run_at_load": true,
|
|
225
|
+
"watch_paths": ["~/.local/share/claude/versions"],
|
|
226
|
+
"platforms": ["darwin"],
|
|
227
|
+
"description": "Grant macOS TCC permissions to newly installed Claude binaries",
|
|
228
|
+
"core": true,
|
|
229
|
+
"recovery_policy": "none",
|
|
230
|
+
"idempotent": true,
|
|
231
|
+
"max_catchup_age": 0,
|
|
232
|
+
"run_on_boot": true,
|
|
233
|
+
"run_on_wake": false
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"id": "prevent-sleep",
|
|
237
|
+
"script": "scripts/nexo-prevent-sleep.sh",
|
|
238
|
+
"type": "shell",
|
|
239
|
+
"keep_alive": true,
|
|
240
|
+
"proc_grep": "caffeinate",
|
|
241
|
+
"requires_power_policy": "always_on",
|
|
242
|
+
"description": "Keep the machine awake while the always-on power policy is enabled",
|
|
243
|
+
"core": true,
|
|
244
|
+
"recovery_policy": "restart",
|
|
245
|
+
"idempotent": true,
|
|
246
|
+
"max_catchup_age": 0,
|
|
247
|
+
"run_on_boot": true,
|
|
248
|
+
"run_on_wake": true
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"id": "dashboard",
|
|
252
|
+
"script": "scripts/nexo-dashboard.sh",
|
|
253
|
+
"type": "shell",
|
|
254
|
+
"keep_alive": true,
|
|
255
|
+
"optional": "dashboard",
|
|
256
|
+
"description": "Keep the NEXO dashboard web UI available on localhost:6174",
|
|
257
|
+
"core": true,
|
|
258
|
+
"recovery_policy": "restart",
|
|
259
|
+
"idempotent": true,
|
|
260
|
+
"max_catchup_age": 0,
|
|
261
|
+
"run_on_boot": true,
|
|
262
|
+
"run_on_wake": true
|
|
263
|
+
},
|
|
207
264
|
{
|
|
208
265
|
"id": "auto-update",
|
|
209
266
|
"script": "scripts/nexo-auto-update.py",
|
package/src/crons/sync.py
CHANGED
|
@@ -32,7 +32,7 @@ if str(_runtime_root) not in sys.path:
|
|
|
32
32
|
sys.path.insert(0, str(_runtime_root))
|
|
33
33
|
|
|
34
34
|
import paths
|
|
35
|
-
from cron_recovery import resolve_declared_schedule, should_run_at_load
|
|
35
|
+
from cron_recovery import is_cron_enabled, resolve_declared_schedule, should_run_at_load
|
|
36
36
|
try:
|
|
37
37
|
from runtime_power import resolve_launchagent_path
|
|
38
38
|
except ImportError:
|
|
@@ -77,6 +77,13 @@ def _runtime_scripts_dir() -> Path:
|
|
|
77
77
|
return new
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def _runtime_code_dir() -> Path:
|
|
81
|
+
packaged = RUNTIME_ROOT / "core"
|
|
82
|
+
if packaged.exists() or not (RUNTIME_ROOT / "server.py").exists():
|
|
83
|
+
return packaged
|
|
84
|
+
return RUNTIME_ROOT
|
|
85
|
+
|
|
86
|
+
|
|
80
87
|
def _runtime_crons_dir() -> Path:
|
|
81
88
|
new = RUNTIME_ROOT / "runtime" / "crons"
|
|
82
89
|
legacy = RUNTIME_ROOT / "crons"
|
|
@@ -104,6 +111,12 @@ def _sync_watchdog_hash_registry():
|
|
|
104
111
|
continue
|
|
105
112
|
file_path, expected_hash = line.split("|", 1)
|
|
106
113
|
if file_path:
|
|
114
|
+
candidate = Path(file_path)
|
|
115
|
+
try:
|
|
116
|
+
if candidate.resolve(strict=False) == watchdog_path.resolve(strict=False):
|
|
117
|
+
continue
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
107
120
|
entries[file_path] = expected_hash
|
|
108
121
|
import hashlib
|
|
109
122
|
entries[str(watchdog_path)] = hashlib.sha256(watchdog_path.read_bytes()).hexdigest()
|
|
@@ -144,6 +157,10 @@ def load_manifest() -> list[dict]:
|
|
|
144
157
|
from automation_controls import apply_core_automation_overrides
|
|
145
158
|
except Exception:
|
|
146
159
|
apply_core_automation_overrides = None
|
|
160
|
+
try:
|
|
161
|
+
from product_mode import filter_blocked_crons
|
|
162
|
+
except Exception:
|
|
163
|
+
filter_blocked_crons = None
|
|
147
164
|
|
|
148
165
|
with open(MANIFEST) as f:
|
|
149
166
|
data = json.load(f)
|
|
@@ -156,24 +173,30 @@ def load_manifest() -> list[dict]:
|
|
|
156
173
|
except Exception as e:
|
|
157
174
|
log(f"WARNING: could not read optionals.json: {e}")
|
|
158
175
|
|
|
159
|
-
|
|
176
|
+
schedule_data: dict = {}
|
|
160
177
|
if SCHEDULE_FILE.is_file():
|
|
161
178
|
try:
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
loaded = json.loads(SCHEDULE_FILE.read_text())
|
|
180
|
+
if isinstance(loaded, dict):
|
|
181
|
+
schedule_data = loaded
|
|
164
182
|
except Exception:
|
|
165
183
|
pass
|
|
166
184
|
|
|
167
185
|
filtered = []
|
|
168
186
|
for cron in crons:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
if not is_cron_enabled(
|
|
188
|
+
cron,
|
|
189
|
+
optionals=enabled_optionals,
|
|
190
|
+
schedule_data=schedule_data,
|
|
191
|
+
system=platform.system(),
|
|
192
|
+
):
|
|
175
193
|
continue
|
|
176
194
|
filtered.append(cron)
|
|
195
|
+
if callable(filter_blocked_crons):
|
|
196
|
+
try:
|
|
197
|
+
filtered = filter_blocked_crons(filtered)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
log(f"WARNING: could not filter product-blocked crons: {e}")
|
|
177
200
|
if callable(apply_core_automation_overrides):
|
|
178
201
|
try:
|
|
179
202
|
return apply_core_automation_overrides(filtered)
|
|
@@ -320,7 +343,7 @@ def build_plist(cron: dict) -> dict:
|
|
|
320
343
|
"PATH": resolve_launchagent_path(),
|
|
321
344
|
"HOME": str(Path.home()),
|
|
322
345
|
"NEXO_HOME": str(NEXO_HOME),
|
|
323
|
-
"NEXO_CODE": str(
|
|
346
|
+
"NEXO_CODE": str(_runtime_code_dir()),
|
|
324
347
|
"NEXO_SOURCE_CODE": str(SOURCE_ROOT),
|
|
325
348
|
"NEXO_MANAGED_CORE_CRON": "1",
|
|
326
349
|
"PYTHONUNBUFFERED": "1",
|
|
@@ -334,6 +357,11 @@ def build_plist(cron: dict) -> dict:
|
|
|
334
357
|
else:
|
|
335
358
|
if should_run_at_load(cron):
|
|
336
359
|
plist["RunAtLoad"] = True
|
|
360
|
+
if cron.get("watch_paths"):
|
|
361
|
+
plist["WatchPaths"] = [
|
|
362
|
+
str(Path(str(path)).expanduser()) if str(path).startswith("~") else str(path)
|
|
363
|
+
for path in cron.get("watch_paths", [])
|
|
364
|
+
]
|
|
337
365
|
if "interval_seconds" in cron and not cron.get("keep_alive"):
|
|
338
366
|
plist["StartInterval"] = cron["interval_seconds"]
|
|
339
367
|
elif "schedule" in cron and not cron.get("keep_alive"):
|
|
@@ -380,6 +408,8 @@ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
|
|
|
380
408
|
return True
|
|
381
409
|
if existing.get("KeepAlive") != new_plist.get("KeepAlive"):
|
|
382
410
|
return True
|
|
411
|
+
if existing.get("WatchPaths") != new_plist.get("WatchPaths"):
|
|
412
|
+
return True
|
|
383
413
|
if existing.get("EnvironmentVariables") != new_plist.get("EnvironmentVariables"):
|
|
384
414
|
return True
|
|
385
415
|
return False
|
|
@@ -418,6 +448,36 @@ def _plist_is_personal(existing: dict) -> bool:
|
|
|
418
448
|
return env.get(PERSONAL_CRON_MANAGED_ENV) == "1" or bool(env.get(PERSONAL_CRON_ID_ENV))
|
|
419
449
|
|
|
420
450
|
|
|
451
|
+
def _core_launchagent_roots() -> tuple[Path, ...]:
|
|
452
|
+
roots: list[Path] = []
|
|
453
|
+
for root in (
|
|
454
|
+
SOURCE_ROOT / "scripts",
|
|
455
|
+
RUNTIME_ROOT / "core" / "scripts",
|
|
456
|
+
RUNTIME_ROOT / "scripts",
|
|
457
|
+
paths.core_scripts_dir(),
|
|
458
|
+
):
|
|
459
|
+
normalized = root.expanduser()
|
|
460
|
+
if normalized not in roots:
|
|
461
|
+
roots.append(normalized)
|
|
462
|
+
return tuple(roots)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _program_arguments_point_to_core(existing: dict) -> bool:
|
|
466
|
+
args = existing.get("ProgramArguments", []) or []
|
|
467
|
+
for arg in args:
|
|
468
|
+
try:
|
|
469
|
+
candidate = Path(str(arg)).expanduser()
|
|
470
|
+
except Exception:
|
|
471
|
+
continue
|
|
472
|
+
for root in _core_launchagent_roots():
|
|
473
|
+
try:
|
|
474
|
+
candidate.relative_to(root)
|
|
475
|
+
return True
|
|
476
|
+
except ValueError:
|
|
477
|
+
continue
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
|
|
421
481
|
def _plist_is_core(existing: dict) -> bool:
|
|
422
482
|
"""Return True when a LaunchAgent should be treated as a core cron."""
|
|
423
483
|
env = existing.get("EnvironmentVariables", {}) or {}
|
|
@@ -427,6 +487,9 @@ def _plist_is_core(existing: dict) -> bool:
|
|
|
427
487
|
if env.get(CORE_CRON_MANAGED_ENV) == "1":
|
|
428
488
|
return True
|
|
429
489
|
|
|
490
|
+
if _program_arguments_point_to_core(existing):
|
|
491
|
+
return True
|
|
492
|
+
|
|
430
493
|
args = existing.get("ProgramArguments", [])
|
|
431
494
|
arg_blob = " ".join(str(a) for a in args)
|
|
432
495
|
return (
|
|
@@ -536,20 +599,25 @@ def sync_linux(dry_run: bool = False):
|
|
|
536
599
|
stdout_log = LOG_DIR / f"{cron_id}-stdout.log"
|
|
537
600
|
stderr_log = LOG_DIR / f"{cron_id}-stderr.log"
|
|
538
601
|
|
|
602
|
+
service_type = "simple" if cron.get("keep_alive") else "oneshot"
|
|
603
|
+
restart_block = "Restart=always\nRestartSec=5\n" if cron.get("keep_alive") else ""
|
|
604
|
+
install_block = "\n[Install]\nWantedBy=default.target\n" if cron.get("keep_alive") else ""
|
|
539
605
|
service_content = f"""[Unit]
|
|
540
606
|
Description=NEXO: {cron.get('description', cron_id)}
|
|
541
607
|
|
|
542
608
|
[Service]
|
|
543
|
-
Type=
|
|
609
|
+
Type={service_type}
|
|
544
610
|
ExecStart={exec_cmd}
|
|
545
611
|
Environment=NEXO_HOME={NEXO_HOME}
|
|
546
|
-
Environment=NEXO_CODE={
|
|
612
|
+
Environment=NEXO_CODE={_runtime_code_dir()}
|
|
547
613
|
Environment=HOME={Path.home()}
|
|
548
614
|
StandardOutput=append:{stdout_log}
|
|
549
615
|
StandardError=append:{stderr_log}
|
|
550
|
-
"""
|
|
616
|
+
{restart_block}{install_block}"""
|
|
551
617
|
|
|
552
|
-
if cron.get("
|
|
618
|
+
if cron.get("keep_alive"):
|
|
619
|
+
timer_spec = ""
|
|
620
|
+
elif cron.get("run_at_load"):
|
|
553
621
|
timer_spec = "OnBootSec=0"
|
|
554
622
|
elif "interval_seconds" in cron:
|
|
555
623
|
timer_spec = f"OnUnitActiveSec={cron['interval_seconds']}s\nOnBootSec=60s"
|
|
@@ -566,6 +634,15 @@ StandardError=append:{stderr_log}
|
|
|
566
634
|
log(f" SKIP {cron_id}: no schedule or interval")
|
|
567
635
|
continue
|
|
568
636
|
|
|
637
|
+
if dry_run:
|
|
638
|
+
log(f" DRY-RUN: would install {cron_id}")
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
service_path.write_text(service_content)
|
|
642
|
+
if cron.get("keep_alive"):
|
|
643
|
+
log(f" Installed keep_alive service: {cron_id}")
|
|
644
|
+
continue
|
|
645
|
+
|
|
569
646
|
timer_content = f"""[Unit]
|
|
570
647
|
Description=NEXO timer: {cron.get('description', cron_id)}
|
|
571
648
|
|
|
@@ -576,23 +653,15 @@ Persistent=true
|
|
|
576
653
|
[Install]
|
|
577
654
|
WantedBy=timers.target
|
|
578
655
|
"""
|
|
579
|
-
|
|
580
|
-
if dry_run:
|
|
581
|
-
log(f" DRY-RUN: would install {cron_id}")
|
|
582
|
-
continue
|
|
583
|
-
|
|
584
|
-
service_path.write_text(service_content)
|
|
585
656
|
timer_path.write_text(timer_content)
|
|
586
657
|
log(f" Installed: {cron_id}")
|
|
587
658
|
|
|
588
659
|
if not dry_run:
|
|
589
660
|
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
|
590
661
|
for cron in manifest_crons:
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
)
|
|
595
|
-
log("systemd timers enabled.")
|
|
662
|
+
unit = f"nexo-{cron['id']}.service" if cron.get("keep_alive") else f"nexo-{cron['id']}.timer"
|
|
663
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", unit], capture_output=True)
|
|
664
|
+
log("systemd units enabled.")
|
|
596
665
|
|
|
597
666
|
log("Sync complete.")
|
|
598
667
|
|
package/src/dashboard/app.py
CHANGED
|
@@ -96,6 +96,60 @@ def _adaptive():
|
|
|
96
96
|
return adaptive_mode
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _dashboard_runtime_flags() -> dict:
|
|
100
|
+
flags = {
|
|
101
|
+
"desktop_managed": False,
|
|
102
|
+
"disabled_features": [],
|
|
103
|
+
"evolution_available": True,
|
|
104
|
+
}
|
|
105
|
+
try:
|
|
106
|
+
from product_mode import desktop_product_requested, load_product_mode
|
|
107
|
+
|
|
108
|
+
mode = load_product_mode()
|
|
109
|
+
disabled = []
|
|
110
|
+
raw_disabled = mode.get("disabled_features") if isinstance(mode, dict) else []
|
|
111
|
+
if isinstance(raw_disabled, (list, tuple, set)):
|
|
112
|
+
disabled = [
|
|
113
|
+
str(item).strip().lower()
|
|
114
|
+
for item in raw_disabled
|
|
115
|
+
if str(item).strip()
|
|
116
|
+
]
|
|
117
|
+
desktop_managed = bool(desktop_product_requested())
|
|
118
|
+
if desktop_managed and "evolution" not in disabled:
|
|
119
|
+
disabled.append("evolution")
|
|
120
|
+
flags["desktop_managed"] = desktop_managed
|
|
121
|
+
flags["disabled_features"] = disabled
|
|
122
|
+
flags["evolution_available"] = "evolution" not in disabled
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
return flags
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _feature_disabled_response(feature: str, *, api: bool = False):
|
|
129
|
+
feature_name = str(feature or "").strip().lower()
|
|
130
|
+
if api:
|
|
131
|
+
return JSONResponse(
|
|
132
|
+
{
|
|
133
|
+
"ok": False,
|
|
134
|
+
"feature": feature_name,
|
|
135
|
+
"disabled": True,
|
|
136
|
+
"reason": "Disabled by NEXO Desktop product contract",
|
|
137
|
+
},
|
|
138
|
+
status_code=403,
|
|
139
|
+
)
|
|
140
|
+
title = f"{feature_name.title()} disabled"
|
|
141
|
+
detail = (
|
|
142
|
+
f"{feature_name.title()} is disabled on Desktop-managed installs. "
|
|
143
|
+
"Use the standalone Brain product if you need that subsystem."
|
|
144
|
+
)
|
|
145
|
+
return _render(
|
|
146
|
+
"feature-disabled.html",
|
|
147
|
+
disabled_feature=feature_name,
|
|
148
|
+
disabled_title=title,
|
|
149
|
+
disabled_detail=detail,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
99
153
|
# ---------------------------------------------------------------------------
|
|
100
154
|
# Pydantic models for request bodies
|
|
101
155
|
# ---------------------------------------------------------------------------
|
|
@@ -431,6 +485,7 @@ def _render(name: str, **ctx) -> HTMLResponse:
|
|
|
431
485
|
"""Render a Jinja2 template with context."""
|
|
432
486
|
try:
|
|
433
487
|
tmpl = jinja_env.get_template(name)
|
|
488
|
+
ctx.setdefault("runtime_flags", _dashboard_runtime_flags())
|
|
434
489
|
return HTMLResponse(tmpl.render(**ctx))
|
|
435
490
|
except Exception as exc:
|
|
436
491
|
import logging
|
|
@@ -507,6 +562,8 @@ async def page_plugins():
|
|
|
507
562
|
# Advanced
|
|
508
563
|
@app.get("/evolution", response_class=HTMLResponse)
|
|
509
564
|
async def page_evolution():
|
|
565
|
+
if not _dashboard_runtime_flags().get("evolution_available", True):
|
|
566
|
+
return _feature_disabled_response("evolution")
|
|
510
567
|
return _render("evolution.html")
|
|
511
568
|
|
|
512
569
|
@app.get("/claims", response_class=HTMLResponse)
|
|
@@ -1646,6 +1703,8 @@ async def api_plugins():
|
|
|
1646
1703
|
|
|
1647
1704
|
@app.get("/api/evolution")
|
|
1648
1705
|
async def api_evolution():
|
|
1706
|
+
if not _dashboard_runtime_flags().get("evolution_available", True):
|
|
1707
|
+
return _feature_disabled_response("evolution", api=True)
|
|
1649
1708
|
db = _db()
|
|
1650
1709
|
conn = db.get_db()
|
|
1651
1710
|
logs = [dict(r) for r in conn.execute("SELECT * FROM evolution_log ORDER BY created_at DESC LIMIT 50").fetchall()]
|
|
@@ -188,10 +188,12 @@
|
|
|
188
188
|
Advanced
|
|
189
189
|
</button>
|
|
190
190
|
<div class="nav-group-items">
|
|
191
|
+
{% if runtime_flags.evolution_available %}
|
|
191
192
|
<a href="/evolution" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="evolution">
|
|
192
193
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
|
193
194
|
Evolution
|
|
194
195
|
</a>
|
|
196
|
+
{% endif %}
|
|
195
197
|
<a href="/claims" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="claims">
|
|
196
198
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
|
197
199
|
Claims Network
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ disabled_title or "Feature disabled" }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
|
7
|
+
<section class="card border border-amber-500/20 bg-amber-500/5 rounded-2xl p-6">
|
|
8
|
+
<div class="flex items-start gap-4">
|
|
9
|
+
<div class="w-11 h-11 rounded-2xl bg-amber-500/15 text-amber-300 flex items-center justify-center text-xl">!</div>
|
|
10
|
+
<div class="space-y-2">
|
|
11
|
+
<p class="text-[11px] font-mono uppercase tracking-[0.28em] text-amber-300/80">
|
|
12
|
+
Product contract
|
|
13
|
+
</p>
|
|
14
|
+
<h1 class="text-2xl font-semibold text-slate-100">
|
|
15
|
+
{{ disabled_title or "Feature disabled" }}
|
|
16
|
+
</h1>
|
|
17
|
+
<p class="text-sm leading-6 text-slate-300">
|
|
18
|
+
{{ disabled_detail or "This feature is currently disabled on this installation." }}
|
|
19
|
+
</p>
|
|
20
|
+
<p class="text-xs text-slate-500">
|
|
21
|
+
Disabled feature: <code>{{ disabled_feature or "unknown" }}</code>
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
</div>
|
|
27
|
+
{% endblock %}
|
|
@@ -13,15 +13,20 @@ import json
|
|
|
13
13
|
import time
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
-
from db._core import get_db
|
|
17
|
-
|
|
18
|
-
|
|
19
16
|
DEFAULT_IMAP_PORT = 993
|
|
20
17
|
DEFAULT_SMTP_PORT = 465
|
|
21
18
|
VALID_ROLES = ("inbox", "outbox", "both")
|
|
22
19
|
VALID_ACCOUNT_TYPES = ("agent", "operator")
|
|
23
20
|
|
|
24
21
|
|
|
22
|
+
def _get_db():
|
|
23
|
+
# Resolve the DB connector lazily so test suites that reload db._core with a
|
|
24
|
+
# temporary NEXO_HOME do not keep talking to a stale shared connection.
|
|
25
|
+
from db._core import get_db
|
|
26
|
+
|
|
27
|
+
return get_db()
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
def _row_to_dict(row) -> dict:
|
|
26
31
|
if row is None:
|
|
27
32
|
return {}
|
|
@@ -95,7 +100,7 @@ def add_email_account(
|
|
|
95
100
|
resolved_is_default = bool(existing.get("is_default")) if is_default is None else bool(is_default)
|
|
96
101
|
if clean_account_type != "operator":
|
|
97
102
|
resolved_is_default = False
|
|
98
|
-
conn =
|
|
103
|
+
conn = _get_db()
|
|
99
104
|
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
100
105
|
# Audit H2: when the caller does not pass `metadata` explicitly,
|
|
101
106
|
# an upsert would otherwise wipe whatever the operator (or another
|
|
@@ -173,7 +178,7 @@ def list_email_accounts(
|
|
|
173
178
|
raise ValueError(
|
|
174
179
|
f"account_type must be one of {VALID_ACCOUNT_TYPES}, got {account_type!r}"
|
|
175
180
|
)
|
|
176
|
-
conn =
|
|
181
|
+
conn = _get_db()
|
|
177
182
|
clauses: list[str] = []
|
|
178
183
|
params: list[Any] = []
|
|
179
184
|
if not include_disabled:
|
|
@@ -192,7 +197,7 @@ def list_email_accounts(
|
|
|
192
197
|
|
|
193
198
|
|
|
194
199
|
def get_email_account(label: str) -> dict | None:
|
|
195
|
-
conn =
|
|
200
|
+
conn = _get_db()
|
|
196
201
|
row = conn.execute(
|
|
197
202
|
"SELECT * FROM email_accounts WHERE label = ?",
|
|
198
203
|
(label,),
|
|
@@ -200,9 +205,24 @@ def get_email_account(label: str) -> dict | None:
|
|
|
200
205
|
return _row_to_dict(row) if row else None
|
|
201
206
|
|
|
202
207
|
|
|
208
|
+
def get_email_account_by_id(account_id: int | str | None) -> dict | None:
|
|
209
|
+
try:
|
|
210
|
+
clean_id = int(account_id or 0)
|
|
211
|
+
except Exception:
|
|
212
|
+
return None
|
|
213
|
+
if clean_id <= 0:
|
|
214
|
+
return None
|
|
215
|
+
conn = _get_db()
|
|
216
|
+
row = conn.execute(
|
|
217
|
+
"SELECT * FROM email_accounts WHERE id = ?",
|
|
218
|
+
(clean_id,),
|
|
219
|
+
).fetchone()
|
|
220
|
+
return _row_to_dict(row) if row else None
|
|
221
|
+
|
|
222
|
+
|
|
203
223
|
def get_primary_email_account() -> dict | None:
|
|
204
224
|
"""Most-recently-updated enabled agent account. Returns None if table empty."""
|
|
205
|
-
conn =
|
|
225
|
+
conn = _get_db()
|
|
206
226
|
row = conn.execute(
|
|
207
227
|
"SELECT * FROM email_accounts WHERE enabled = 1 "
|
|
208
228
|
"AND COALESCE(account_type, 'agent') = 'agent' "
|
|
@@ -213,7 +233,7 @@ def get_primary_email_account() -> dict | None:
|
|
|
213
233
|
|
|
214
234
|
def get_default_operator_email_account() -> dict | None:
|
|
215
235
|
"""Return the explicit default operator mailbox, if any."""
|
|
216
|
-
conn =
|
|
236
|
+
conn = _get_db()
|
|
217
237
|
row = conn.execute(
|
|
218
238
|
"SELECT * FROM email_accounts "
|
|
219
239
|
"WHERE enabled = 1 AND COALESCE(account_type, 'agent') = 'operator' "
|
|
@@ -223,20 +243,48 @@ def get_default_operator_email_account() -> dict | None:
|
|
|
223
243
|
return _row_to_dict(row) if row else None
|
|
224
244
|
|
|
225
245
|
|
|
226
|
-
def set_email_account_enabled(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
246
|
+
def set_email_account_enabled(
|
|
247
|
+
label: str | None = None,
|
|
248
|
+
enabled: bool = True,
|
|
249
|
+
account_id: int | str | None = None,
|
|
250
|
+
) -> bool:
|
|
251
|
+
conn = _get_db()
|
|
252
|
+
if account_id is not None:
|
|
253
|
+
try:
|
|
254
|
+
clean_id = int(account_id)
|
|
255
|
+
except Exception:
|
|
256
|
+
clean_id = 0
|
|
257
|
+
if clean_id > 0:
|
|
258
|
+
cur = conn.execute(
|
|
259
|
+
"UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
|
|
260
|
+
"WHERE id = ?",
|
|
261
|
+
(1 if enabled else 0, clean_id),
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
cur = conn.execute("SELECT 0 WHERE 1=0")
|
|
265
|
+
else:
|
|
266
|
+
cur = conn.execute(
|
|
267
|
+
"UPDATE email_accounts SET enabled = ?, updated_at = datetime('now') "
|
|
268
|
+
"WHERE label = ?",
|
|
269
|
+
(1 if enabled else 0, label),
|
|
270
|
+
)
|
|
233
271
|
conn.commit()
|
|
234
272
|
return cur.rowcount > 0
|
|
235
273
|
|
|
236
274
|
|
|
237
|
-
def remove_email_account(label: str) -> bool:
|
|
238
|
-
conn =
|
|
239
|
-
|
|
275
|
+
def remove_email_account(label: str | None = None, account_id: int | str | None = None) -> bool:
|
|
276
|
+
conn = _get_db()
|
|
277
|
+
if account_id is not None:
|
|
278
|
+
try:
|
|
279
|
+
clean_id = int(account_id)
|
|
280
|
+
except Exception:
|
|
281
|
+
clean_id = 0
|
|
282
|
+
if clean_id > 0:
|
|
283
|
+
cur = conn.execute("DELETE FROM email_accounts WHERE id = ?", (clean_id,))
|
|
284
|
+
else:
|
|
285
|
+
cur = conn.execute("SELECT 0 WHERE 1=0")
|
|
286
|
+
else:
|
|
287
|
+
cur = conn.execute("DELETE FROM email_accounts WHERE label = ?", (label,))
|
|
240
288
|
conn.commit()
|
|
241
289
|
return cur.rowcount > 0
|
|
242
290
|
|
|
@@ -245,6 +293,7 @@ __all__ = [
|
|
|
245
293
|
"add_email_account",
|
|
246
294
|
"list_email_accounts",
|
|
247
295
|
"get_email_account",
|
|
296
|
+
"get_email_account_by_id",
|
|
248
297
|
"get_primary_email_account",
|
|
249
298
|
"get_default_operator_email_account",
|
|
250
299
|
"set_email_account_enabled",
|
package/src/db/_fts.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""NEXO DB — Fts module."""
|
|
2
2
|
import os, pathlib, sqlite3, threading, datetime
|
|
3
|
+
import paths
|
|
3
4
|
from db._core import get_db, now_epoch, DB_PATH
|
|
4
5
|
|
|
5
6
|
NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
|
|
@@ -10,12 +11,12 @@ NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
|
|
|
10
11
|
_FTS_MD_DIRS = [
|
|
11
12
|
os.path.join(NEXO_HOME, "docs"),
|
|
12
13
|
os.path.join(NEXO_HOME, "projects"),
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
str(paths.memory_dir()),
|
|
15
|
+
str(paths.operations_dir()),
|
|
15
16
|
os.path.join(NEXO_HOME, "learnings"),
|
|
16
|
-
|
|
17
|
+
str(paths.brain_dir()),
|
|
17
18
|
os.path.join(NEXO_HOME, "agents"),
|
|
18
|
-
|
|
19
|
+
str(paths.personal_skills_dir()),
|
|
19
20
|
]
|
|
20
21
|
# Code repos: index source files (skip vendor, node_modules, etc.)
|
|
21
22
|
_FTS_CODE_DIRS = [] # Users can add project dirs via nexo_index_add_dir
|
|
@@ -403,4 +404,3 @@ def _migrate_add_index(conn, index_name: str, table: str, column: str):
|
|
|
403
404
|
conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table}({column})")
|
|
404
405
|
conn.commit()
|
|
405
406
|
|
|
406
|
-
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
"""NEXO DB — Personal scripts registry.
|
|
3
3
|
|
|
4
|
-
Filesystem remains the source of truth for personal scripts in NEXO_HOME/scripts/.
|
|
4
|
+
Filesystem remains the source of truth for personal scripts in NEXO_HOME/personal/scripts/.
|
|
5
5
|
SQLite stores operational metadata so NEXO can reason about what scripts exist,
|
|
6
6
|
what they do, and which schedules/plists are attached to them.
|
|
7
7
|
"""
|
package/src/db/_skills.py
CHANGED
|
@@ -34,8 +34,8 @@ def _resolve_core_skills_dir() -> Path:
|
|
|
34
34
|
|
|
35
35
|
In development NEXO_CODE points at repo/src, so core skills live in src/skills.
|
|
36
36
|
In packaged installs the runtime wrapper points NEXO_CODE at NEXO_HOME, so core
|
|
37
|
-
skills must live in a dedicated
|
|
38
|
-
personal skills in NEXO_HOME/skills.
|
|
37
|
+
skills must live in a dedicated core/skills directory to avoid colliding with
|
|
38
|
+
personal skills in NEXO_HOME/personal/skills.
|
|
39
39
|
"""
|
|
40
40
|
try:
|
|
41
41
|
if NEXO_CODE.resolve() == NEXO_HOME.resolve():
|
|
@@ -1472,7 +1472,7 @@ def collect_skill_improvement_candidates() -> list[dict]:
|
|
|
1472
1472
|
|
|
1473
1473
|
|
|
1474
1474
|
def materialize_personal_skill_definition(skill_data: dict) -> dict:
|
|
1475
|
-
"""Write a personal skill definition to NEXO_HOME/skills and sync it into DB."""
|
|
1475
|
+
"""Write a personal skill definition to NEXO_HOME/personal/skills and sync it into DB."""
|
|
1476
1476
|
_ensure_skill_dirs()
|
|
1477
1477
|
skill_id = str(skill_data.get("id", "")).strip()
|
|
1478
1478
|
name = str(skill_data.get("name", "")).strip()
|