nexo-brain 2.6.5 → 2.6.6
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 +911 -143
- package/bin/nexo-brain.js +139 -0
- package/package.json +1 -1
- package/src/auto_update.py +14 -1
- package/src/cli.py +24 -1
- package/src/doctor/providers/runtime.py +5 -0
- package/src/runtime_power.py +416 -0
package/bin/nexo-brain.js
CHANGED
|
@@ -31,6 +31,7 @@ const LAUNCH_AGENTS = path.join(
|
|
|
31
31
|
"Library",
|
|
32
32
|
"LaunchAgents"
|
|
33
33
|
);
|
|
34
|
+
const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
|
34
35
|
|
|
35
36
|
function isEphemeralInstall(nexoHome) {
|
|
36
37
|
const homeDir = require("os").homedir();
|
|
@@ -114,6 +115,139 @@ function logMacPermissionsNotice(nexoHome, pythonPath = "") {
|
|
|
114
115
|
log(" System Settings → Privacy & Security → Full Disk Access");
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
function getRuntimePythonTargets(pythonPath = "") {
|
|
119
|
+
const candidates = [];
|
|
120
|
+
const venvPy = path.join(NEXO_HOME, ".venv", "bin", "python3");
|
|
121
|
+
if (fs.existsSync(venvPy)) candidates.push(venvPy);
|
|
122
|
+
if (pythonPath) candidates.push(pythonPath);
|
|
123
|
+
const discovered = run("which python3") || run("which python") || "";
|
|
124
|
+
if (discovered) candidates.push(discovered);
|
|
125
|
+
return [...new Set(candidates.filter(Boolean))];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function detectFullDiskAccessReasons(nexoHome) {
|
|
129
|
+
if (process.platform !== "darwin") return [];
|
|
130
|
+
const reasons = [];
|
|
131
|
+
if (isProtectedMacPath(nexoHome)) {
|
|
132
|
+
reasons.push(`NEXO_HOME is inside a protected macOS folder: ${nexoHome}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const logsDir = path.join(nexoHome, "logs");
|
|
136
|
+
if (fs.existsSync(logsDir)) {
|
|
137
|
+
const candidates = fs.readdirSync(logsDir).filter((name) => name.endsWith("-stderr.log"));
|
|
138
|
+
for (const name of candidates) {
|
|
139
|
+
try {
|
|
140
|
+
const text = fs.readFileSync(path.join(logsDir, name), "utf8");
|
|
141
|
+
if (text.includes("Operation not permitted")) {
|
|
142
|
+
reasons.push(`Recent background job stderr hit 'Operation not permitted' (${name})`);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return reasons;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function probeFullDiskAccess(nexoHome) {
|
|
152
|
+
if (process.platform !== "darwin") {
|
|
153
|
+
return { checked: false, granted: null, probePath: "", message: "macOS-only" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const candidates = [
|
|
157
|
+
path.join(require("os").homedir(), "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
158
|
+
path.join(require("os").homedir(), "Library", "Mail"),
|
|
159
|
+
path.join(require("os").homedir(), "Library", "Messages"),
|
|
160
|
+
path.join(require("os").homedir(), "Library", "Safari"),
|
|
161
|
+
path.join(require("os").homedir(), "Library", "Application Support", "AddressBook"),
|
|
162
|
+
].filter((item) => fs.existsSync(item));
|
|
163
|
+
|
|
164
|
+
if (isProtectedMacPath(nexoHome)) candidates.push(nexoHome);
|
|
165
|
+
if (!candidates.length) {
|
|
166
|
+
return { checked: false, granted: null, probePath: "", message: "No probe path available." };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
for (const candidate of candidates) {
|
|
171
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
172
|
+
seen.add(candidate);
|
|
173
|
+
const result = spawnSync("/bin/bash", [
|
|
174
|
+
"-lc",
|
|
175
|
+
'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi',
|
|
176
|
+
"_",
|
|
177
|
+
candidate,
|
|
178
|
+
], { encoding: "utf8" });
|
|
179
|
+
if (result.status === 0) {
|
|
180
|
+
return { checked: true, granted: true, probePath: candidate, message: "" };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { checked: true, granted: false, probePath: candidates[0], message: "Could not verify Full Disk Access yet." };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function maybeConfigureFullDiskAccess(schedule, useDefaults, pythonPath = "") {
|
|
187
|
+
const current = String((schedule && schedule.full_disk_access_status) || "unset").toLowerCase();
|
|
188
|
+
schedule.full_disk_access_status_version = 1;
|
|
189
|
+
const reasons = detectFullDiskAccessReasons(NEXO_HOME);
|
|
190
|
+
schedule.full_disk_access_reasons = reasons;
|
|
191
|
+
|
|
192
|
+
if (process.platform !== "darwin" || !reasons.length) {
|
|
193
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
194
|
+
return schedule;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (current === "granted") {
|
|
198
|
+
const probe = probeFullDiskAccess(NEXO_HOME);
|
|
199
|
+
if (probe.granted) {
|
|
200
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
201
|
+
return schedule;
|
|
202
|
+
}
|
|
203
|
+
schedule.full_disk_access_status = "later";
|
|
204
|
+
} else if (current === "declined") {
|
|
205
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
206
|
+
return schedule;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
210
|
+
schedule.full_disk_access_status = current === "granted" ? "later" : current || "unset";
|
|
211
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
212
|
+
return schedule;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log("");
|
|
216
|
+
log("Optional macOS Full Disk Access guidance:");
|
|
217
|
+
log("macOS does not allow granting this automatically. NEXO can only open the correct System Settings screen and verify best effort.");
|
|
218
|
+
log("Reason(s) detected:");
|
|
219
|
+
reasons.forEach((item) => log(` - ${item}`));
|
|
220
|
+
log("If you proceed, add your terminal app and, if needed for background jobs, these binaries:");
|
|
221
|
+
log(" - /bin/bash");
|
|
222
|
+
getRuntimePythonTargets(pythonPath).forEach((item) => log(` - ${item}`));
|
|
223
|
+
|
|
224
|
+
const answer = (await ask(" Open Full Disk Access setup now? [y/N/later]: ")).trim().toLowerCase();
|
|
225
|
+
if (answer === "y" || answer === "yes") {
|
|
226
|
+
spawnSync("open", [MACOS_FDA_SETTINGS_URL], { stdio: "ignore" });
|
|
227
|
+
log("Opened System Settings → Privacy & Security → Full Disk Access.");
|
|
228
|
+
const followUp = (await ask(" Press Enter after granting it, or type later to skip for now: ")).trim().toLowerCase();
|
|
229
|
+
if (followUp === "later" || followUp === "l") {
|
|
230
|
+
schedule.full_disk_access_status = "later";
|
|
231
|
+
} else {
|
|
232
|
+
const probe = probeFullDiskAccess(NEXO_HOME);
|
|
233
|
+
if (probe.granted) {
|
|
234
|
+
schedule.full_disk_access_status = "granted";
|
|
235
|
+
log(`Full Disk Access verified via ${probe.probePath}.`);
|
|
236
|
+
} else {
|
|
237
|
+
schedule.full_disk_access_status = "later";
|
|
238
|
+
log("Could not verify Full Disk Access yet. NEXO will remind you later if background jobs still hit TCC.");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else if (answer === "later" || answer === "l" || answer === "") {
|
|
242
|
+
schedule.full_disk_access_status = "later";
|
|
243
|
+
} else {
|
|
244
|
+
schedule.full_disk_access_status = "declined";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
|
|
248
|
+
return schedule;
|
|
249
|
+
}
|
|
250
|
+
|
|
117
251
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
118
252
|
// CORE PROCESS & HOOK DEFINITIONS
|
|
119
253
|
// All core nightly/periodic processes and all 8 core hooks that make NEXO functional.
|
|
@@ -296,6 +430,9 @@ function getDefaultSchedule(timezone) {
|
|
|
296
430
|
auto_update: true,
|
|
297
431
|
power_policy: "unset",
|
|
298
432
|
power_policy_version: 2,
|
|
433
|
+
full_disk_access_status: "unset",
|
|
434
|
+
full_disk_access_status_version: 1,
|
|
435
|
+
full_disk_access_reasons: [],
|
|
299
436
|
processes: {
|
|
300
437
|
"cognitive-decay": { hour: 3, minute: 0 },
|
|
301
438
|
"postmortem": { hour: 23, minute: 30 },
|
|
@@ -844,6 +981,7 @@ async function main() {
|
|
|
844
981
|
let migSchedule = loadOrCreateSchedule(NEXO_HOME);
|
|
845
982
|
migSchedule = await maybeConfigurePowerPolicy(migSchedule, useDefaults);
|
|
846
983
|
const migPython = findVenvPython(NEXO_HOME) || "python3";
|
|
984
|
+
migSchedule = await maybeConfigureFullDiskAccess(migSchedule, useDefaults, migPython);
|
|
847
985
|
let migOptionals = {};
|
|
848
986
|
try {
|
|
849
987
|
const optFile = path.join(NEXO_HOME, "config", "optionals.json");
|
|
@@ -2116,6 +2254,7 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
|
|
|
2116
2254
|
log("Setting up automated processes...");
|
|
2117
2255
|
let schedule = loadOrCreateSchedule(NEXO_HOME);
|
|
2118
2256
|
schedule = await maybeConfigurePowerPolicy(schedule, useDefaults);
|
|
2257
|
+
schedule = await maybeConfigureFullDiskAccess(schedule, useDefaults, python);
|
|
2119
2258
|
const enabledOptionals = { dashboard: doDashboard };
|
|
2120
2259
|
if (isEphemeralInstall(NEXO_HOME)) {
|
|
2121
2260
|
log("Ephemeral HOME/NEXO_HOME detected — skipping LaunchAgents installation.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.6",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
|
|
6
6
|
"bin": {
|
package/src/auto_update.py
CHANGED
|
@@ -1522,15 +1522,26 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1522
1522
|
"migrations": [],
|
|
1523
1523
|
"power_policy": None,
|
|
1524
1524
|
"power_message": None,
|
|
1525
|
+
"full_disk_access_status": None,
|
|
1526
|
+
"full_disk_access_message": None,
|
|
1525
1527
|
"error": None,
|
|
1526
1528
|
}
|
|
1527
1529
|
|
|
1528
|
-
from runtime_power import
|
|
1530
|
+
from runtime_power import (
|
|
1531
|
+
apply_power_policy,
|
|
1532
|
+
ensure_power_policy_choice,
|
|
1533
|
+
get_power_policy,
|
|
1534
|
+
ensure_full_disk_access_choice,
|
|
1535
|
+
get_full_disk_access_status,
|
|
1536
|
+
)
|
|
1529
1537
|
|
|
1530
1538
|
choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
|
|
1531
1539
|
power_result = apply_power_policy(choice.get("policy"))
|
|
1540
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason=entrypoint)
|
|
1532
1541
|
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1533
1542
|
result["power_message"] = power_result.get("message")
|
|
1543
|
+
result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
|
|
1544
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
1534
1545
|
if power_result.get("ok"):
|
|
1535
1546
|
result["actions"].append(f"power:{power_result.get('action')}")
|
|
1536
1547
|
|
|
@@ -1604,6 +1615,8 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
|
|
|
1604
1615
|
result["entrypoint"] = entrypoint
|
|
1605
1616
|
result["power_policy"] = choice.get("policy") or get_power_policy()
|
|
1606
1617
|
result["power_message"] = power_result.get("message")
|
|
1618
|
+
result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
|
|
1619
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
1607
1620
|
if power_result.get("ok"):
|
|
1608
1621
|
actions = result.setdefault("actions", [])
|
|
1609
1622
|
actions.append(f"power:{power_result.get('action')}")
|
package/src/cli.py
CHANGED
|
@@ -451,7 +451,13 @@ def _update(args):
|
|
|
451
451
|
- Packaged/runtime-only install: delegate to plugins.update handle_update()
|
|
452
452
|
"""
|
|
453
453
|
from auto_update import manual_sync_update, _resolve_sync_source
|
|
454
|
-
from runtime_power import
|
|
454
|
+
from runtime_power import (
|
|
455
|
+
ensure_power_policy_choice,
|
|
456
|
+
apply_power_policy,
|
|
457
|
+
format_power_policy_label,
|
|
458
|
+
ensure_full_disk_access_choice,
|
|
459
|
+
format_full_disk_access_label,
|
|
460
|
+
)
|
|
455
461
|
|
|
456
462
|
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
457
463
|
|
|
@@ -472,6 +478,7 @@ def _update(args):
|
|
|
472
478
|
result = handle_update()
|
|
473
479
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
474
480
|
power_result = apply_power_policy(choice.get("policy"))
|
|
481
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
475
482
|
if args.json:
|
|
476
483
|
print(json.dumps({
|
|
477
484
|
"mode": "packaged",
|
|
@@ -479,6 +486,9 @@ def _update(args):
|
|
|
479
486
|
"power_policy": choice.get("policy"),
|
|
480
487
|
"power_action": power_result.get("action"),
|
|
481
488
|
"power_details": power_result.get("details"),
|
|
489
|
+
"full_disk_access_status": fda_choice.get("status"),
|
|
490
|
+
"full_disk_access_reasons": fda_choice.get("reasons"),
|
|
491
|
+
"full_disk_access_message": fda_choice.get("message"),
|
|
482
492
|
}, indent=2, ensure_ascii=False))
|
|
483
493
|
else:
|
|
484
494
|
print(result)
|
|
@@ -486,16 +496,25 @@ def _update(args):
|
|
|
486
496
|
print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
487
497
|
if power_result.get("message"):
|
|
488
498
|
print(f"Power helper: {power_result.get('message')}")
|
|
499
|
+
if fda_choice.get("prompted"):
|
|
500
|
+
print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
501
|
+
if fda_choice.get("message"):
|
|
502
|
+
print(f"Full Disk Access: {fda_choice.get('message')}")
|
|
489
503
|
return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
|
|
490
504
|
|
|
491
505
|
choice = ensure_power_policy_choice(interactive=interactive, reason="update")
|
|
492
506
|
power_result = apply_power_policy(choice.get("policy"))
|
|
507
|
+
fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
|
|
493
508
|
result = manual_sync_update(interactive=interactive, allow_source_pull=True)
|
|
494
509
|
result["power_policy"] = choice.get("policy")
|
|
495
510
|
result["power_action"] = power_result.get("action")
|
|
496
511
|
result["power_details"] = power_result.get("details")
|
|
512
|
+
result["full_disk_access_status"] = fda_choice.get("status")
|
|
513
|
+
result["full_disk_access_reasons"] = fda_choice.get("reasons")
|
|
497
514
|
if power_result.get("message"):
|
|
498
515
|
result["power_message"] = power_result.get("message")
|
|
516
|
+
if fda_choice.get("message"):
|
|
517
|
+
result["full_disk_access_message"] = fda_choice.get("message")
|
|
499
518
|
if args.json:
|
|
500
519
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
501
520
|
else:
|
|
@@ -511,6 +530,10 @@ def _update(args):
|
|
|
511
530
|
print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
|
|
512
531
|
if power_result.get("message"):
|
|
513
532
|
print(f" Power helper: {power_result.get('message')}")
|
|
533
|
+
if fda_choice.get("prompted"):
|
|
534
|
+
print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
|
|
535
|
+
if fda_choice.get("message"):
|
|
536
|
+
print(f" Full Disk Access: {fda_choice.get('message')}")
|
|
514
537
|
else:
|
|
515
538
|
print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
|
|
516
539
|
return 0 if result.get("ok") else 1
|
|
@@ -747,6 +747,11 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
747
747
|
"and treat recent 'Operation not permitted' against Documents/Desktop/Downloads as a TCC/runtime path issue."
|
|
748
748
|
),
|
|
749
749
|
)
|
|
750
|
+
if tcc_risk:
|
|
751
|
+
check.repair_plan.append(
|
|
752
|
+
"On macOS, grant Full Disk Access manually if protected folders are required; "
|
|
753
|
+
"NEXO can only open the System Settings pane and verify best effort"
|
|
754
|
+
)
|
|
750
755
|
|
|
751
756
|
if fix:
|
|
752
757
|
sync_ok, sync_evidence = _sync_launchagents_from_manifest()
|