nexo-brain 7.9.3 → 7.9.4
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 -1
- package/bin/nexo-brain.js +328 -34
- package/bin/postinstall.js +23 -2
- package/package.json +4 -4
- package/src/cli.py +25 -0
- package/src/lifecycle_events.py +167 -18
- package/src/lifecycle_prompts.py +13 -3
- package/src/model_warmup.py +177 -0
- package/src/plugins/lifecycle_events.py +26 -0
- package/templates/CLAUDE.md.template +11 -1
- package/templates/CODEX.AGENTS.md.template +9 -1
- package/tool-enforcement-map.json +13 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.4",
|
|
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.9.
|
|
21
|
+
Version `7.9.4` is the current packaged-runtime line. Patch release that blocks the Brain 7.9.3 + Desktop 0.28.2 diary regression: canonical lifecycle plans now require real `session_diary` evidence (`wait_for_diary_write`) before `stop_session`, and canonical completion is rejected/retryable without that diary row. It also fixes npm CLI onboarding so `nexo-brain --version` and subcommands never launch the wizard when legacy/v2 calibration is already valid, commits setup calibration atomically only after the wizard completes, and adds `nexo-brain warmup-models` so install/update paths predownload the local mDeBERTa/BGE/reranker models. Verification: full Brain pytest (`2189 passed, 3 skipped, 1 xfailed, 5 xpassed`), release-readiness, npm pack dry-run, and coordinated Desktop v0.28.3 checks.
|
|
22
|
+
|
|
23
|
+
Previously in `7.9.3`: patch release that hardens Brain's canonical lifecycle plan for Desktop close/archive/delete/app-exit diary guarantees: `canonical_actions` now publish the v2 canonical shape (`type` plus `payload.prompt`) while keeping one-release compatibility mirrors (`kind` plus top-level `prompt`) for older Desktop clients. This lets Desktop execute resume → diary prompt → stop with one exact owner per lifecycle event and preserve Brain-side dedupe by event id. Targeted verification: `pytest tests/test_lifecycle_events.py` (25 passing) plus release-readiness after artifact sync.
|
|
22
24
|
|
|
23
25
|
Previously in `7.9.2`: patch release that completes the Brain semantic-router site migration: the remaining decision callers now route through `semantic_router.route(...)` with named `decision_kind` policies (`r20_constant_change`, `r34_identity_coherence`, `t4_r15`, `t4_r23e`, `t4_r23f`, `t4_r23h`, `followup_operator_attention`, `drive_signal_type`, `drive_area`, `reply_event_type`, `query_intent`, and `sentiment_intent`). Brain now owns model choice, thresholds, and fallback behaviour centrally instead of each caller carrying its own classifier policy. The patch also fixes packaged headless Guardian map loading: `enforcement_engine` and `agent_runner` now check the installed core directory (`~/.nexo/core/tool-enforcement-map.json`) so followup-runner, morning-agent, digest, and email-monitor load the map instead of falling back to unguarded subprocess execution. Targeted verification: 100 semantic/router/enforcer tests, 125 Drive/cognitive/productization tests, and release-readiness passing.
|
|
24
26
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -177,13 +177,27 @@ function isEphemeralInstall(nexoHome) {
|
|
|
177
177
|
.some((candidate) => Array.from(tempRoots).some((root) => isWithin(candidate, root)));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
let rl = null;
|
|
181
|
+
|
|
182
|
+
function getReadline() {
|
|
183
|
+
if (!rl) {
|
|
184
|
+
rl = readline.createInterface({
|
|
185
|
+
input: process.stdin,
|
|
186
|
+
output: process.stdout,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return rl;
|
|
190
|
+
}
|
|
184
191
|
|
|
185
192
|
function ask(question) {
|
|
186
|
-
return new Promise((resolve) =>
|
|
193
|
+
return new Promise((resolve) => getReadline().question(question, resolve));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function closeReadline() {
|
|
197
|
+
if (rl) {
|
|
198
|
+
rl.close();
|
|
199
|
+
rl = null;
|
|
200
|
+
}
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
function run(cmd, opts = {}) {
|
|
@@ -198,6 +212,277 @@ function log(msg) {
|
|
|
198
212
|
console.log(` ${msg}`);
|
|
199
213
|
}
|
|
200
214
|
|
|
215
|
+
function readPackageJson() {
|
|
216
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeJsonAtomic(targetPath, payload) {
|
|
220
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
221
|
+
const tmpPath = path.join(
|
|
222
|
+
path.dirname(targetPath),
|
|
223
|
+
`.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`
|
|
224
|
+
);
|
|
225
|
+
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n");
|
|
226
|
+
fs.renameSync(tmpPath, targetPath);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function readJsonFile(filePath) {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function calibrationPathCandidates(nexoHome) {
|
|
238
|
+
return [
|
|
239
|
+
path.join(nexoHome, "personal", "brain", "calibration.json"),
|
|
240
|
+
path.join(nexoHome, "brain", "calibration.json"),
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readRuntimeCalibration(nexoHome = NEXO_HOME) {
|
|
245
|
+
for (const filePath of calibrationPathCandidates(nexoHome)) {
|
|
246
|
+
if (!fs.existsSync(filePath)) continue;
|
|
247
|
+
const payload = readJsonFile(filePath);
|
|
248
|
+
if (payload && typeof payload === "object") {
|
|
249
|
+
return { path: filePath, payload };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { path: null, payload: null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function nonEmptyString(value) {
|
|
256
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isPlaceholderUserName(value) {
|
|
260
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
261
|
+
return normalized === "" || normalized === "usuario";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isOnboardingComplete(calibration) {
|
|
265
|
+
if (!calibration || typeof calibration !== "object") return false;
|
|
266
|
+
const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
|
|
267
|
+
const name = user.name;
|
|
268
|
+
const language = user.language || calibration.language;
|
|
269
|
+
const meta = calibration.meta && typeof calibration.meta === "object" ? calibration.meta : {};
|
|
270
|
+
|
|
271
|
+
if (meta.onboarding_completed === true) {
|
|
272
|
+
return nonEmptyString(name) && nonEmptyString(language);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Legacy fallback: v7.8/v7.9-era calibration files may not carry the
|
|
276
|
+
// current schema marker, but a real operator name + language is enough to
|
|
277
|
+
// prove the setup was completed. Placeholder defaults stay incomplete.
|
|
278
|
+
return nonEmptyString(name) && !isPlaceholderUserName(name) && nonEmptyString(language);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function hasPartialPlaceholderCalibration(calibration) {
|
|
282
|
+
if (!calibration || typeof calibration !== "object") return false;
|
|
283
|
+
const meta = calibration.meta && typeof calibration.meta === "object" ? calibration.meta : {};
|
|
284
|
+
if (meta.onboarding_completed === true) return false;
|
|
285
|
+
const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
|
|
286
|
+
const name = user.name;
|
|
287
|
+
const language = user.language || calibration.language;
|
|
288
|
+
return isPlaceholderUserName(name) || !nonEmptyString(language);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ensureOnboardingCompletionMarker(nexoHome = NEXO_HOME) {
|
|
292
|
+
const record = readRuntimeCalibration(nexoHome);
|
|
293
|
+
if (!record.path || !isOnboardingComplete(record.payload)) {
|
|
294
|
+
return { changed: false, complete: false, path: record.path };
|
|
295
|
+
}
|
|
296
|
+
const meta = record.payload.meta && typeof record.payload.meta === "object"
|
|
297
|
+
? { ...record.payload.meta }
|
|
298
|
+
: {};
|
|
299
|
+
if (meta.onboarding_completed === true) {
|
|
300
|
+
return { changed: false, complete: true, path: record.path };
|
|
301
|
+
}
|
|
302
|
+
const next = {
|
|
303
|
+
...record.payload,
|
|
304
|
+
version: Math.max(Number(record.payload.version) || 1, 2),
|
|
305
|
+
meta: {
|
|
306
|
+
...meta,
|
|
307
|
+
onboarding_completed: true,
|
|
308
|
+
onboarding_completed_at: meta.onboarding_completed_at || new Date().toISOString(),
|
|
309
|
+
migrated_from_legacy_calibration: true,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
writeJsonAtomic(record.path, next);
|
|
313
|
+
return { changed: true, complete: true, path: record.path };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const WARMUP_SCRIPT = path.join(__dirname, "..", "src", "model_warmup.py");
|
|
317
|
+
const WARMUP_PIP_PACKAGES = [
|
|
318
|
+
"transformers",
|
|
319
|
+
"torch",
|
|
320
|
+
"sentencepiece",
|
|
321
|
+
"sentence-transformers",
|
|
322
|
+
];
|
|
323
|
+
const WARMUP_TIMEOUT_MS = 60 * 60 * 1000;
|
|
324
|
+
|
|
325
|
+
function shouldSkipModelWarmup() {
|
|
326
|
+
const flag = String(process.env.NEXO_SKIP_MODEL_WARMUP || "").trim().toLowerCase();
|
|
327
|
+
return ["1", "true", "yes", "on"].includes(flag);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveSystemPython() {
|
|
331
|
+
return run("which python3") || run("which python") || "python3";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ensureWarmupPython(nexoHome = NEXO_HOME) {
|
|
335
|
+
const existing = findVenvPython(nexoHome);
|
|
336
|
+
if (existing) return existing;
|
|
337
|
+
|
|
338
|
+
const basePython = resolveSystemPython();
|
|
339
|
+
const venvPath = path.join(nexoHome, ".venv");
|
|
340
|
+
const venvPython = process.platform === "win32"
|
|
341
|
+
? path.join(venvPath, "Scripts", "python.exe")
|
|
342
|
+
: path.join(venvPath, "bin", "python3");
|
|
343
|
+
fs.mkdirSync(nexoHome, { recursive: true });
|
|
344
|
+
if (!fs.existsSync(venvPython)) {
|
|
345
|
+
log(" Creating Python virtual environment for model warmup...");
|
|
346
|
+
const result = spawnSync(basePython, ["-m", "venv", venvPath], { stdio: "inherit", timeout: 120000 });
|
|
347
|
+
if (result.status !== 0) {
|
|
348
|
+
throw new Error("could not create Python virtual environment for model warmup");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return venvPython;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function installWarmupPythonDependencies(pythonPath, { quiet = false, installRuntimeDeps = true } = {}) {
|
|
355
|
+
const requirementsFile = path.join(__dirname, "..", "src", "requirements.txt");
|
|
356
|
+
const pipCommon = ["-m", "pip", "install"];
|
|
357
|
+
if (quiet) pipCommon.push("--quiet");
|
|
358
|
+
const stdio = quiet ? "pipe" : "inherit";
|
|
359
|
+
|
|
360
|
+
if (installRuntimeDeps && fs.existsSync(requirementsFile)) {
|
|
361
|
+
const reqResult = spawnSync(
|
|
362
|
+
pythonPath,
|
|
363
|
+
[...pipCommon, "-r", requirementsFile],
|
|
364
|
+
{ stdio, timeout: WARMUP_TIMEOUT_MS }
|
|
365
|
+
);
|
|
366
|
+
if (reqResult.status !== 0) {
|
|
367
|
+
throw new Error("failed to install runtime Python dependencies for model warmup");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const classifierResult = spawnSync(
|
|
372
|
+
pythonPath,
|
|
373
|
+
[...pipCommon, ...WARMUP_PIP_PACKAGES],
|
|
374
|
+
{ stdio, timeout: WARMUP_TIMEOUT_MS }
|
|
375
|
+
);
|
|
376
|
+
if (classifierResult.status !== 0) {
|
|
377
|
+
throw new Error("failed to install local classifier dependencies for model warmup");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function runModelWarmup(pythonPath, {
|
|
382
|
+
nexoHome = NEXO_HOME,
|
|
383
|
+
dryRun = false,
|
|
384
|
+
json = false,
|
|
385
|
+
strict = true,
|
|
386
|
+
quiet = false,
|
|
387
|
+
} = {}) {
|
|
388
|
+
if (!fs.existsSync(WARMUP_SCRIPT)) {
|
|
389
|
+
throw new Error(`model warmup script not found: ${WARMUP_SCRIPT}`);
|
|
390
|
+
}
|
|
391
|
+
const args = [WARMUP_SCRIPT];
|
|
392
|
+
if (dryRun) args.push("--dry-run");
|
|
393
|
+
if (json) args.push("--json");
|
|
394
|
+
if (strict) args.push("--strict");
|
|
395
|
+
const result = spawnSync(pythonPath, args, {
|
|
396
|
+
cwd: path.join(__dirname, "..", "src"),
|
|
397
|
+
env: { ...process.env, NEXO_HOME: nexoHome, NEXO_CODE: path.join(__dirname, "..", "src") },
|
|
398
|
+
stdio: json ? "pipe" : (quiet ? "pipe" : "inherit"),
|
|
399
|
+
encoding: json || quiet ? "utf8" : undefined,
|
|
400
|
+
timeout: WARMUP_TIMEOUT_MS,
|
|
401
|
+
});
|
|
402
|
+
if (json && result.stdout) process.stdout.write(result.stdout);
|
|
403
|
+
if (json && result.stderr) process.stderr.write(result.stderr);
|
|
404
|
+
if (result.status !== 0) {
|
|
405
|
+
const details = json || quiet
|
|
406
|
+
? String(result.stderr || result.stdout || "").trim()
|
|
407
|
+
: "";
|
|
408
|
+
throw new Error(details || `model warmup failed with exit ${result.status}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function runMandatoryModelWarmup(pythonPath, nexoHome = NEXO_HOME, { reason = "install", installRuntimeDeps = true } = {}) {
|
|
413
|
+
if (shouldSkipModelWarmup()) {
|
|
414
|
+
log(`Model warmup skipped by NEXO_SKIP_MODEL_WARMUP during ${reason}.`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
log(`Warming up local Brain/Desktop models (${reason})...`);
|
|
418
|
+
installWarmupPythonDependencies(pythonPath, { quiet: false, installRuntimeDeps });
|
|
419
|
+
runModelWarmup(pythonPath, { nexoHome, strict: true });
|
|
420
|
+
log("Local model warmup complete.");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function runWarmupModelsCommand(args) {
|
|
424
|
+
const dryRun = args.includes("--dry-run");
|
|
425
|
+
const json = args.includes("--json");
|
|
426
|
+
const force = args.includes("--force");
|
|
427
|
+
const quiet = args.includes("--postinstall") || args.includes("--quiet");
|
|
428
|
+
|
|
429
|
+
if (shouldSkipModelWarmup() && !force) {
|
|
430
|
+
if (json) {
|
|
431
|
+
process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason: "NEXO_SKIP_MODEL_WARMUP" }) + "\n");
|
|
432
|
+
} else {
|
|
433
|
+
log("Model warmup skipped by NEXO_SKIP_MODEL_WARMUP.");
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const pythonPath = dryRun ? resolveSystemPython() : ensureWarmupPython(NEXO_HOME);
|
|
439
|
+
if (!dryRun) {
|
|
440
|
+
installWarmupPythonDependencies(pythonPath, { quiet });
|
|
441
|
+
}
|
|
442
|
+
runModelWarmup(pythonPath, {
|
|
443
|
+
nexoHome: NEXO_HOME,
|
|
444
|
+
dryRun,
|
|
445
|
+
json,
|
|
446
|
+
strict: !args.includes("--best-effort"),
|
|
447
|
+
quiet,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function printHelp() {
|
|
452
|
+
const version = readPackageJson().version;
|
|
453
|
+
console.log(`nexo-brain ${version}`);
|
|
454
|
+
console.log("");
|
|
455
|
+
console.log("Usage:");
|
|
456
|
+
console.log(" nexo-brain [--yes|--skip|--defaults]");
|
|
457
|
+
console.log(" nexo-brain --version");
|
|
458
|
+
console.log(" nexo-brain warmup-models [--dry-run] [--json] [--force]");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function maybeHandleTopLevelCommand(argv = process.argv.slice(2)) {
|
|
462
|
+
if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
|
|
463
|
+
console.log(`nexo-brain ${readPackageJson().version}`);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
467
|
+
printHelp();
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
if (argv[0] === "warmup-models") {
|
|
471
|
+
await runWarmupModelsCommand(argv.slice(1));
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const setupFlags = new Set(["--defaults", "--yes", "--skip", "-y"]);
|
|
476
|
+
const unknownCommand = argv.find((arg) => !setupFlags.has(arg));
|
|
477
|
+
if (unknownCommand && !unknownCommand.startsWith("-")) {
|
|
478
|
+
console.error(`Unknown nexo-brain command: ${unknownCommand}`);
|
|
479
|
+
console.error("Run 'nexo-brain --help' for usage.");
|
|
480
|
+
process.exitCode = 2;
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
201
486
|
function duplicateArtifactCanonicalName(name) {
|
|
202
487
|
const ext = path.extname(name);
|
|
203
488
|
const stem = ext ? name.slice(0, -ext.length) : name;
|
|
@@ -1946,7 +2231,7 @@ WantedBy=timers.target
|
|
|
1946
2231
|
}
|
|
1947
2232
|
}
|
|
1948
2233
|
|
|
1949
|
-
async function
|
|
2234
|
+
async function runSetup() {
|
|
1950
2235
|
// Non-interactive mode: --defaults, --yes, --skip, or -y all skip prompts
|
|
1951
2236
|
// and apply the recommended defaults end-to-end (v6.0.0 adds --skip).
|
|
1952
2237
|
const useDefaults = process.argv.includes("--defaults")
|
|
@@ -1993,12 +2278,22 @@ async function main() {
|
|
|
1993
2278
|
process.exit(1);
|
|
1994
2279
|
}
|
|
1995
2280
|
|
|
2281
|
+
const onboardingMigration = ensureOnboardingCompletionMarker(NEXO_HOME);
|
|
2282
|
+
if (onboardingMigration.changed) {
|
|
2283
|
+
log("Migrated legacy calibration completion marker.");
|
|
2284
|
+
} else {
|
|
2285
|
+
const calibrationRecord = readRuntimeCalibration(NEXO_HOME);
|
|
2286
|
+
if (hasPartialPlaceholderCalibration(calibrationRecord.payload)) {
|
|
2287
|
+
log("Incomplete calibration detected; restarting onboarding cleanly.");
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
1996
2291
|
// Auto-migration: detect existing installation
|
|
1997
2292
|
const versionFile = path.join(NEXO_HOME, "version.json");
|
|
1998
2293
|
if (fs.existsSync(versionFile)) {
|
|
1999
2294
|
try {
|
|
2000
2295
|
const installed = JSON.parse(fs.readFileSync(versionFile, "utf8"));
|
|
2001
|
-
const currentPkg =
|
|
2296
|
+
const currentPkg = readPackageJson();
|
|
2002
2297
|
const installedVersion = installed.version || "0.0.0";
|
|
2003
2298
|
const currentVersion = currentPkg.version;
|
|
2004
2299
|
|
|
@@ -2079,6 +2374,9 @@ async function main() {
|
|
|
2079
2374
|
log(" Python dependencies reconciled.");
|
|
2080
2375
|
}
|
|
2081
2376
|
|
|
2377
|
+
const migPythonForWarmup = findVenvPython(NEXO_HOME) || "python3";
|
|
2378
|
+
runMandatoryModelWarmup(migPythonForWarmup, NEXO_HOME, { reason: "update", installRuntimeDeps: false });
|
|
2379
|
+
|
|
2082
2380
|
// Update plugins (all .py files in plugins/)
|
|
2083
2381
|
const pluginsSrc = path.join(srcDir, "plugins");
|
|
2084
2382
|
const pluginsDest = path.join(NEXO_HOME, "core", "plugins");
|
|
@@ -2246,11 +2544,12 @@ async function main() {
|
|
|
2246
2544
|
log(`Migration complete: v${installedVersion} → v${currentVersion}`);
|
|
2247
2545
|
log("Your data (memories, learnings, preferences) is untouched.");
|
|
2248
2546
|
console.log("");
|
|
2249
|
-
|
|
2547
|
+
closeReadline();
|
|
2250
2548
|
return;
|
|
2251
2549
|
}
|
|
2252
2550
|
|
|
2253
2551
|
// Same version — backfill crons/ if missing (for installs before crons was shipped)
|
|
2552
|
+
const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
|
|
2254
2553
|
const cronsDest = resolveRuntimeCronsDir(NEXO_HOME);
|
|
2255
2554
|
const cronsSrc = path.join(__dirname, "..", "src", "crons");
|
|
2256
2555
|
if (fs.existsSync(cronsSrc)) {
|
|
@@ -2268,7 +2567,6 @@ async function main() {
|
|
|
2268
2567
|
log("Refreshed crons/ directory.");
|
|
2269
2568
|
|
|
2270
2569
|
const cronSyncPath = path.join(cronsSrc, "sync.py");
|
|
2271
|
-
const syncPython = findVenvPython(NEXO_HOME) || run("which python3") || "python3";
|
|
2272
2570
|
if (fs.existsSync(cronSyncPath)) {
|
|
2273
2571
|
const syncResult = spawnSync(syncPython, [cronSyncPath], {
|
|
2274
2572
|
env: { ...process.env, NEXO_HOME, NEXO_CODE: path.join(__dirname, "..", "src") },
|
|
@@ -2340,10 +2638,11 @@ async function main() {
|
|
|
2340
2638
|
throw new Error(`F0.6 layout finalization failed: ${syncLayoutFinalize.error}`);
|
|
2341
2639
|
}
|
|
2342
2640
|
|
|
2641
|
+
runMandatoryModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
|
|
2343
2642
|
logMacPermissionsNotice(NEXO_HOME, syncPython);
|
|
2344
2643
|
|
|
2345
2644
|
log(`Already at v${currentVersion}. No migration needed.`);
|
|
2346
|
-
|
|
2645
|
+
closeReadline();
|
|
2347
2646
|
return;
|
|
2348
2647
|
} catch (e) {
|
|
2349
2648
|
// Version file corrupt — proceed with fresh install
|
|
@@ -2732,18 +3031,14 @@ async function main() {
|
|
|
2732
3031
|
report_style: "essentials_only",
|
|
2733
3032
|
execution_first: true,
|
|
2734
3033
|
},
|
|
2735
|
-
meta: {
|
|
3034
|
+
meta: {
|
|
3035
|
+
onboarding_completed: true,
|
|
3036
|
+
onboarding_completed_at: new Date().toISOString(),
|
|
3037
|
+
},
|
|
2736
3038
|
auto_install: "ask", // updated later if user answers P11
|
|
2737
3039
|
calibrated_at: new Date().toISOString(),
|
|
2738
3040
|
};
|
|
2739
|
-
// Ensure NEXO_HOME and brain dir exist before writing calibration
|
|
2740
3041
|
const runtimeBrainDir = resolveRuntimeBrainDir(NEXO_HOME);
|
|
2741
|
-
fs.mkdirSync(NEXO_HOME, { recursive: true });
|
|
2742
|
-
fs.mkdirSync(runtimeBrainDir, { recursive: true });
|
|
2743
|
-
fs.writeFileSync(
|
|
2744
|
-
path.join(runtimeBrainDir, "calibration.json"),
|
|
2745
|
-
JSON.stringify(calibration, null, 2)
|
|
2746
|
-
);
|
|
2747
3042
|
|
|
2748
3043
|
// Step 5: Deep scan (P9) — v6.0.0 defaults flip to ON when running in
|
|
2749
3044
|
// --yes/--skip mode; the interactive prompt below defaults to "yes" too
|
|
@@ -2792,28 +3087,18 @@ async function main() {
|
|
|
2792
3087
|
console.log("");
|
|
2793
3088
|
}
|
|
2794
3089
|
|
|
2795
|
-
//
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
JSON.stringify(calibration, null, 2)
|
|
2800
|
-
);
|
|
2801
|
-
} catch (_) {}
|
|
3090
|
+
// Commit calibration only after the wizard completed. This prevents Ctrl-C
|
|
3091
|
+
// from leaving placeholder defaults that look like real onboarding.
|
|
3092
|
+
fs.mkdirSync(NEXO_HOME, { recursive: true });
|
|
3093
|
+
writeJsonAtomic(path.join(runtimeBrainDir, "calibration.json"), calibration);
|
|
2802
3094
|
|
|
2803
3095
|
if (smokeTestMode) {
|
|
2804
3096
|
// Pytest fresh-install smoke only needs to prove that the non-interactive
|
|
2805
3097
|
// onboarding path writes the current calibration shape. Skip the rest of
|
|
2806
3098
|
// the heavy bootstrap (client installs, pip, scan, LaunchAgents) so the
|
|
2807
3099
|
// smoke does not sit on long dependency timeouts inside sandboxes.
|
|
2808
|
-
try {
|
|
2809
|
-
fs.mkdirSync(runtimeBrainDir, { recursive: true });
|
|
2810
|
-
fs.writeFileSync(
|
|
2811
|
-
path.join(runtimeBrainDir, "calibration.json"),
|
|
2812
|
-
JSON.stringify(calibration, null, 2)
|
|
2813
|
-
);
|
|
2814
|
-
} catch (_) {}
|
|
2815
3100
|
log("Smoke test mode detected — wrote calibration and skipped heavy bootstrap.");
|
|
2816
|
-
|
|
3101
|
+
closeReadline();
|
|
2817
3102
|
return;
|
|
2818
3103
|
}
|
|
2819
3104
|
|
|
@@ -2862,6 +3147,7 @@ async function main() {
|
|
|
2862
3147
|
python = venvPython;
|
|
2863
3148
|
}
|
|
2864
3149
|
log("Dependencies installed.");
|
|
3150
|
+
runMandatoryModelWarmup(python, NEXO_HOME, { reason: "install", installRuntimeDeps: false });
|
|
2865
3151
|
|
|
2866
3152
|
// Step 4: Create ~/.nexo/
|
|
2867
3153
|
log("Setting up NEXO home...");
|
|
@@ -3915,10 +4201,18 @@ See ~/.nexo/ for configuration.
|
|
|
3915
4201
|
console.log(` \u255A${"═".repeat(bw - 2)}\u255D`);
|
|
3916
4202
|
console.log("");
|
|
3917
4203
|
|
|
3918
|
-
|
|
4204
|
+
closeReadline();
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
async function main() {
|
|
4208
|
+
const handled = await maybeHandleTopLevelCommand();
|
|
4209
|
+
if (!handled) {
|
|
4210
|
+
await runSetup();
|
|
4211
|
+
}
|
|
3919
4212
|
}
|
|
3920
4213
|
|
|
3921
4214
|
main().catch((err) => {
|
|
4215
|
+
closeReadline();
|
|
3922
4216
|
console.error("Setup failed:", err.message);
|
|
3923
4217
|
process.exit(1);
|
|
3924
4218
|
});
|
package/bin/postinstall.js
CHANGED
|
@@ -8,15 +8,29 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require("fs");
|
|
10
10
|
const path = require("path");
|
|
11
|
+
const { execFileSync } = require("child_process");
|
|
11
12
|
|
|
12
13
|
const NEXO_HOME = process.env.NEXO_HOME || path.join(require("os").homedir(), ".nexo");
|
|
13
14
|
const VERSION_FILE = path.join(NEXO_HOME, "version.json");
|
|
15
|
+
const INSTALLER = path.join(__dirname, "nexo-brain.js");
|
|
14
16
|
|
|
15
17
|
if (process.env.NEXO_SKIP_POSTINSTALL === "1") {
|
|
16
18
|
// Called during rollback — skip migration to avoid loops
|
|
17
19
|
process.exit(0);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function runModelWarmup(reason) {
|
|
23
|
+
if (process.env.NEXO_SKIP_MODEL_WARMUP === "1") {
|
|
24
|
+
console.log(`\n NEXO Brain: model warmup skipped by NEXO_SKIP_MODEL_WARMUP (${reason}).`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.log(`\n NEXO Brain: warming up local models (${reason})...`);
|
|
28
|
+
execFileSync(process.execPath, [INSTALLER, "warmup-models", "--postinstall"], {
|
|
29
|
+
stdio: "inherit",
|
|
30
|
+
env: { ...process.env, NEXO_POSTINSTALL: "1", NEXO_HOME: NEXO_HOME }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
if (fs.existsSync(VERSION_FILE)) {
|
|
21
35
|
// Existing installation — run auto-migration silently
|
|
22
36
|
const installed = JSON.parse(fs.readFileSync(VERSION_FILE, "utf8"));
|
|
@@ -32,9 +46,8 @@ if (fs.existsSync(VERSION_FILE)) {
|
|
|
32
46
|
// Run the main installer in --yes mode (non-interactive)
|
|
33
47
|
// It will detect the existing version and do migration only
|
|
34
48
|
// Let errors propagate so npm reports the failure correctly
|
|
35
|
-
const { execFileSync } = require("child_process");
|
|
36
49
|
try {
|
|
37
|
-
execFileSync(process.execPath, [
|
|
50
|
+
execFileSync(process.execPath, [INSTALLER, "--yes"], {
|
|
38
51
|
stdio: "inherit",
|
|
39
52
|
env: { ...process.env, NEXO_POSTINSTALL: "1", NEXO_HOME: NEXO_HOME }
|
|
40
53
|
});
|
|
@@ -44,6 +57,14 @@ if (fs.existsSync(VERSION_FILE)) {
|
|
|
44
57
|
process.exit(1);
|
|
45
58
|
}
|
|
46
59
|
} else {
|
|
60
|
+
try {
|
|
61
|
+
runModelWarmup("fresh install");
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`\n NEXO Brain: model warmup FAILED — ${e.message}`);
|
|
64
|
+
console.error(" Set NEXO_SKIP_MODEL_WARMUP=1 only for CI/offline testing, then run 'nexo-brain warmup-models'.");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
// Fresh install — just show instructions
|
|
48
69
|
console.log("\n ╔════════════════════════════════════════════╗");
|
|
49
70
|
console.log(" ║ NEXO Brain installed successfully! ║");
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.4",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"nexo-brain": "
|
|
9
|
-
"nexo": "
|
|
8
|
+
"nexo-brain": "bin/nexo-brain.js",
|
|
9
|
+
"nexo": "bin/nexo.js"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"claude-code",
|
package/src/cli.py
CHANGED
|
@@ -3104,6 +3104,14 @@ def main():
|
|
|
3104
3104
|
help="JSON array of per-action outcomes, e.g. '[{\"action_id\":\"a1\",\"status\":\"ok\"}]'",
|
|
3105
3105
|
)
|
|
3106
3106
|
|
|
3107
|
+
lwait_p = lifecycle_sub.add_parser(
|
|
3108
|
+
"wait-for-diary",
|
|
3109
|
+
help="v7.9: wait for concrete session_diary evidence before canonical stop_session",
|
|
3110
|
+
)
|
|
3111
|
+
lwait_p.add_argument("--event-id", required=True)
|
|
3112
|
+
lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
|
|
3113
|
+
lwait_p.add_argument("--poll-ms", type=int, default=500)
|
|
3114
|
+
|
|
3107
3115
|
# Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
|
|
3108
3116
|
quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
|
|
3109
3117
|
quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
|
|
@@ -3370,6 +3378,23 @@ def main():
|
|
|
3370
3378
|
if status == "retryable_error":
|
|
3371
3379
|
return 2
|
|
3372
3380
|
return 3
|
|
3381
|
+
if args.lifecycle_command == "wait-for-diary":
|
|
3382
|
+
out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_diary(
|
|
3383
|
+
event_id=args.event_id,
|
|
3384
|
+
timeout_ms=args.timeout_ms,
|
|
3385
|
+
poll_ms=args.poll_ms,
|
|
3386
|
+
)
|
|
3387
|
+
print(out)
|
|
3388
|
+
try:
|
|
3389
|
+
parsed = _json.loads(out)
|
|
3390
|
+
status = str(parsed.get("status", ""))
|
|
3391
|
+
except Exception:
|
|
3392
|
+
status = ""
|
|
3393
|
+
if status == "ok":
|
|
3394
|
+
return 0
|
|
3395
|
+
if status == "retryable_error":
|
|
3396
|
+
return 2
|
|
3397
|
+
return 3
|
|
3373
3398
|
lifecycle_parser.print_help()
|
|
3374
3399
|
return 1
|
|
3375
3400
|
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
package/src/lifecycle_events.py
CHANGED
|
@@ -51,6 +51,7 @@ Actions that carry a canonical plan: ``close``, ``delete``, ``archive``,
|
|
|
51
51
|
from __future__ import annotations
|
|
52
52
|
|
|
53
53
|
import json
|
|
54
|
+
import time
|
|
54
55
|
from typing import Any, Dict, Optional
|
|
55
56
|
|
|
56
57
|
from db import get_db
|
|
@@ -83,24 +84,102 @@ def _normalise_payload(obj: Any) -> str:
|
|
|
83
84
|
return "{}"
|
|
84
85
|
|
|
85
86
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
guard: if the live session already produced a diary since the
|
|
90
|
-
plan was handed out, we must NOT re-dispatch it.
|
|
91
|
-
"""
|
|
92
|
-
if not session_id or not dispatched_at:
|
|
93
|
-
return False
|
|
87
|
+
def _max_session_diary_id(conn, session_id: str) -> int:
|
|
88
|
+
if not session_id:
|
|
89
|
+
return 0
|
|
94
90
|
try:
|
|
95
91
|
row = conn.execute(
|
|
96
|
-
"SELECT
|
|
97
|
-
|
|
98
|
-
(str(session_id), str(dispatched_at)),
|
|
92
|
+
"SELECT COALESCE(MAX(id), 0) FROM session_diary WHERE session_id = ?",
|
|
93
|
+
(str(session_id),),
|
|
99
94
|
).fetchone()
|
|
95
|
+
except Exception:
|
|
96
|
+
return 0
|
|
97
|
+
try:
|
|
98
|
+
return int(row[0] or 0) if row else 0
|
|
99
|
+
except Exception:
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _diary_checkpoint_from_actions_json(actions_json: Optional[str]) -> int:
|
|
104
|
+
if not actions_json:
|
|
105
|
+
return 0
|
|
106
|
+
try:
|
|
107
|
+
actions = json.loads(actions_json)
|
|
108
|
+
except Exception:
|
|
109
|
+
return 0
|
|
110
|
+
if not isinstance(actions, list):
|
|
111
|
+
return 0
|
|
112
|
+
for action in actions:
|
|
113
|
+
if not isinstance(action, dict):
|
|
114
|
+
continue
|
|
115
|
+
action_type = action.get("type") or action.get("kind")
|
|
116
|
+
if action_type != "wait_for_diary_write":
|
|
117
|
+
continue
|
|
118
|
+
payload = action.get("payload") if isinstance(action.get("payload"), dict) else {}
|
|
119
|
+
raw = payload.get("after_session_diary_id", action.get("after_session_diary_id", 0))
|
|
120
|
+
try:
|
|
121
|
+
return int(raw or 0)
|
|
122
|
+
except Exception:
|
|
123
|
+
return 0
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _attach_diary_checkpoint(plan: Dict[str, Any], checkpoint_id: int) -> Dict[str, Any]:
|
|
128
|
+
"""Store the session_diary high-water mark inside the persisted plan.
|
|
129
|
+
|
|
130
|
+
``created_at`` only has second precision in old installs. The
|
|
131
|
+
checkpoint makes diary confirmation robust even when dispatch and
|
|
132
|
+
diary write happen in the same second.
|
|
133
|
+
"""
|
|
134
|
+
actions = []
|
|
135
|
+
for action in list((plan or {}).get("canonical_actions") or []):
|
|
136
|
+
item = dict(action or {})
|
|
137
|
+
action_type = item.get("type") or item.get("kind")
|
|
138
|
+
if action_type == "wait_for_diary_write":
|
|
139
|
+
payload = dict(item.get("payload") or {})
|
|
140
|
+
payload["after_session_diary_id"] = int(checkpoint_id or 0)
|
|
141
|
+
item["payload"] = payload
|
|
142
|
+
item["after_session_diary_id"] = int(checkpoint_id or 0)
|
|
143
|
+
actions.append(item)
|
|
144
|
+
updated = dict(plan or {})
|
|
145
|
+
updated["canonical_actions"] = actions
|
|
146
|
+
return updated
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _session_diary_evidence(
|
|
150
|
+
conn,
|
|
151
|
+
session_id: str,
|
|
152
|
+
dispatched_at: Optional[str],
|
|
153
|
+
actions_json: Optional[str] = None,
|
|
154
|
+
) -> Optional[Dict[str, Any]]:
|
|
155
|
+
"""Return the concrete session_diary row that satisfies a plan."""
|
|
156
|
+
if not session_id or not dispatched_at:
|
|
157
|
+
return None
|
|
158
|
+
checkpoint_id = _diary_checkpoint_from_actions_json(actions_json)
|
|
159
|
+
try:
|
|
160
|
+
if checkpoint_id > 0:
|
|
161
|
+
row = conn.execute(
|
|
162
|
+
"SELECT id, created_at FROM session_diary "
|
|
163
|
+
"WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT 1",
|
|
164
|
+
(str(session_id), int(checkpoint_id)),
|
|
165
|
+
).fetchone()
|
|
166
|
+
else:
|
|
167
|
+
row = conn.execute(
|
|
168
|
+
"SELECT id, created_at FROM session_diary "
|
|
169
|
+
"WHERE session_id = ? AND created_at >= ? ORDER BY created_at ASC, id ASC LIMIT 1",
|
|
170
|
+
(str(session_id), str(dispatched_at)),
|
|
171
|
+
).fetchone()
|
|
100
172
|
except Exception:
|
|
101
173
|
# Missing table on a minimal test harness — treat as "no diary".
|
|
102
|
-
return
|
|
103
|
-
|
|
174
|
+
return None
|
|
175
|
+
if row is None:
|
|
176
|
+
return None
|
|
177
|
+
return {"session_diary_id": row[0], "created_at": row[1]}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], actions_json: Optional[str] = None) -> bool:
|
|
181
|
+
"""True if session_diary has evidence satisfying the canonical plan."""
|
|
182
|
+
return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
|
|
104
183
|
|
|
105
184
|
|
|
106
185
|
def record_lifecycle_event(
|
|
@@ -145,6 +224,8 @@ def record_lifecycle_event(
|
|
|
145
224
|
session_id=str(session_id) if session_id else None,
|
|
146
225
|
payload_snapshot=payload_snapshot or {},
|
|
147
226
|
)
|
|
227
|
+
if plan is not None and session_id:
|
|
228
|
+
plan = _attach_diary_checkpoint(plan, _max_session_diary_id(conn, str(session_id)))
|
|
148
229
|
|
|
149
230
|
if existing is not None:
|
|
150
231
|
status = str(existing[0] or "")
|
|
@@ -171,7 +252,12 @@ def record_lifecycle_event(
|
|
|
171
252
|
# the intent has already been satisfied by the model and we
|
|
172
253
|
# must NOT ask Desktop to re-run the plan.
|
|
173
254
|
if prior_plan_id and prior_dispatched_at and not prior_done_at:
|
|
174
|
-
if session_id and _session_diary_since(
|
|
255
|
+
if session_id and _session_diary_since(
|
|
256
|
+
conn,
|
|
257
|
+
str(session_id),
|
|
258
|
+
str(prior_dispatched_at),
|
|
259
|
+
str(prior_actions_json or ""),
|
|
260
|
+
):
|
|
175
261
|
conn.execute(
|
|
176
262
|
"UPDATE lifecycle_events "
|
|
177
263
|
"SET delivery_status = 'already_processed', "
|
|
@@ -340,7 +426,8 @@ def record_complete_canonical(
|
|
|
340
426
|
|
|
341
427
|
conn = get_db()
|
|
342
428
|
row = conn.execute(
|
|
343
|
-
"SELECT delivery_status, canonical_plan_id, canonical_done_at "
|
|
429
|
+
"SELECT delivery_status, canonical_plan_id, canonical_done_at, "
|
|
430
|
+
"action, session_id, canonical_dispatched_at, canonical_actions_json "
|
|
344
431
|
"FROM lifecycle_events WHERE event_id = ?",
|
|
345
432
|
(str(event_id),),
|
|
346
433
|
).fetchone()
|
|
@@ -349,6 +436,10 @@ def record_complete_canonical(
|
|
|
349
436
|
current_status = str(row[0] or "")
|
|
350
437
|
expected_plan = row[1]
|
|
351
438
|
already_done_at = row[2]
|
|
439
|
+
action = str(row[3] or "")
|
|
440
|
+
session_id = str(row[4] or "")
|
|
441
|
+
dispatched_at = row[5]
|
|
442
|
+
actions_json = row[6]
|
|
352
443
|
|
|
353
444
|
if expected_plan and canonical_plan_id != expected_plan:
|
|
354
445
|
return {
|
|
@@ -369,18 +460,27 @@ def record_complete_canonical(
|
|
|
369
460
|
str((r or {}).get("status", "")).lower() not in {"ok", "success", "already_processed"}
|
|
370
461
|
for r in results_list
|
|
371
462
|
)
|
|
372
|
-
|
|
463
|
+
diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
|
|
464
|
+
diary_required = action in _DIARY_TRIGGERING and bool(session_id)
|
|
465
|
+
diary_missing = diary_required and diary_evidence is None
|
|
466
|
+
effective = "retryable_error" if (any_failure or diary_missing) else "canonical_done"
|
|
467
|
+
last_error = None
|
|
468
|
+
if any_failure:
|
|
469
|
+
last_error = "one-or-more-actions-failed"
|
|
470
|
+
elif diary_missing:
|
|
471
|
+
last_error = "canonical-diary-not-confirmed"
|
|
373
472
|
conn.execute(
|
|
374
473
|
"UPDATE lifecycle_events "
|
|
375
474
|
"SET delivery_status = ?, "
|
|
376
|
-
" canonical_done_at = datetime('now'), "
|
|
475
|
+
" canonical_done_at = CASE WHEN ? = 'canonical_done' THEN datetime('now') ELSE NULL END, "
|
|
377
476
|
" canonical_done_results = ?, "
|
|
378
477
|
" last_error = ? "
|
|
379
478
|
"WHERE event_id = ?",
|
|
380
479
|
(
|
|
480
|
+
effective,
|
|
381
481
|
effective,
|
|
382
482
|
json.dumps(results_list, ensure_ascii=False),
|
|
383
|
-
|
|
483
|
+
last_error,
|
|
384
484
|
str(event_id),
|
|
385
485
|
),
|
|
386
486
|
)
|
|
@@ -390,9 +490,58 @@ def record_complete_canonical(
|
|
|
390
490
|
"event_id": event_id,
|
|
391
491
|
"canonical_plan_id": canonical_plan_id,
|
|
392
492
|
"failed_actions": any_failure,
|
|
493
|
+
"diary_confirmed": diary_evidence is not None,
|
|
494
|
+
"diary_required": diary_required,
|
|
495
|
+
"session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
|
|
496
|
+
"reason": "canonical-diary-not-confirmed" if diary_missing else None,
|
|
393
497
|
}
|
|
394
498
|
|
|
395
499
|
|
|
500
|
+
def wait_for_canonical_diary(
|
|
501
|
+
event_id: str,
|
|
502
|
+
timeout_ms: int = 45_000,
|
|
503
|
+
poll_ms: int = 500,
|
|
504
|
+
) -> Dict[str, Any]:
|
|
505
|
+
"""Poll until the lifecycle event has concrete session_diary evidence."""
|
|
506
|
+
if not event_id:
|
|
507
|
+
return {"status": "rejected", "reason": "missing-event-id"}
|
|
508
|
+
timeout_s = max(0.0, float(timeout_ms or 0) / 1000.0)
|
|
509
|
+
poll_s = max(0.05, float(poll_ms or 500) / 1000.0)
|
|
510
|
+
deadline = time.monotonic() + timeout_s
|
|
511
|
+
last_error: Optional[str] = None
|
|
512
|
+
|
|
513
|
+
while True:
|
|
514
|
+
conn = get_db()
|
|
515
|
+
row = conn.execute(
|
|
516
|
+
"SELECT session_id, canonical_dispatched_at, canonical_actions_json "
|
|
517
|
+
"FROM lifecycle_events WHERE event_id = ?",
|
|
518
|
+
(str(event_id),),
|
|
519
|
+
).fetchone()
|
|
520
|
+
if row is None:
|
|
521
|
+
return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
|
|
522
|
+
session_id = str(row[0] or "")
|
|
523
|
+
if not session_id:
|
|
524
|
+
return {"status": "rejected", "reason": "missing-session-id", "event_id": event_id}
|
|
525
|
+
evidence = _session_diary_evidence(conn, session_id, row[1], row[2])
|
|
526
|
+
if evidence is not None:
|
|
527
|
+
return {
|
|
528
|
+
"status": "ok",
|
|
529
|
+
"event_id": event_id,
|
|
530
|
+
"session_id": session_id,
|
|
531
|
+
"diary_confirmed": True,
|
|
532
|
+
**evidence,
|
|
533
|
+
}
|
|
534
|
+
if time.monotonic() >= deadline:
|
|
535
|
+
return {
|
|
536
|
+
"status": "retryable_error",
|
|
537
|
+
"event_id": event_id,
|
|
538
|
+
"session_id": session_id,
|
|
539
|
+
"diary_confirmed": False,
|
|
540
|
+
"reason": last_error or "diary-confirm-timeout",
|
|
541
|
+
}
|
|
542
|
+
time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
|
|
543
|
+
|
|
544
|
+
|
|
396
545
|
def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
|
|
397
546
|
if not event_id:
|
|
398
547
|
return None
|
package/src/lifecycle_prompts.py
CHANGED
|
@@ -19,7 +19,7 @@ import json
|
|
|
19
19
|
from typing import Any, Dict, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
PLAN_VERSION =
|
|
22
|
+
PLAN_VERSION = 3
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# Actions that trigger a canonical diary+stop plan. `switch` and
|
|
@@ -34,7 +34,8 @@ DIARY_TRIGGERING_ACTIONS = {"close", "delete", "archive", "app-exit"}
|
|
|
34
34
|
# executes each action; on timeout it reports status=failed and Brain
|
|
35
35
|
# flips delivery_status to retryable_error.
|
|
36
36
|
DEFAULT_RESUME_TIMEOUT_MS = 2_000
|
|
37
|
-
DEFAULT_INJECT_TIMEOUT_MS =
|
|
37
|
+
DEFAULT_INJECT_TIMEOUT_MS = 30_000
|
|
38
|
+
DEFAULT_DIARY_WAIT_TIMEOUT_MS = 45_000
|
|
38
39
|
DEFAULT_STOP_TIMEOUT_MS = 3_000
|
|
39
40
|
|
|
40
41
|
|
|
@@ -139,7 +140,16 @@ def build_canonical_plan(
|
|
|
139
140
|
prompt=prompt,
|
|
140
141
|
expected_tool_call="nexo_session_diary_write",
|
|
141
142
|
),
|
|
142
|
-
_canonical_action(
|
|
143
|
+
_canonical_action(
|
|
144
|
+
"a3",
|
|
145
|
+
"wait_for_diary_write",
|
|
146
|
+
str(session_id),
|
|
147
|
+
DEFAULT_DIARY_WAIT_TIMEOUT_MS,
|
|
148
|
+
event_id=str(event_id),
|
|
149
|
+
expected_tool_call="nexo_session_diary_write",
|
|
150
|
+
evidence="session_diary",
|
|
151
|
+
),
|
|
152
|
+
_canonical_action("a4", "stop_session", str(session_id), DEFAULT_STOP_TIMEOUT_MS),
|
|
143
153
|
]
|
|
144
154
|
return {
|
|
145
155
|
"canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Predownload the local models used by NEXO Brain/Desktop flows.
|
|
2
|
+
|
|
3
|
+
The installer invokes this script after Python dependencies are present.
|
|
4
|
+
``--dry-run`` is intentionally dependency-free so package tests can verify the
|
|
5
|
+
contract without downloading 1+ GB of weights.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SRC_DIR = Path(__file__).resolve().parent
|
|
21
|
+
if str(SRC_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(SRC_DIR))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class WarmupTarget:
|
|
27
|
+
name: str
|
|
28
|
+
kind: str
|
|
29
|
+
model_id: str
|
|
30
|
+
source: str
|
|
31
|
+
revision: str | None = None
|
|
32
|
+
required: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def warmup_targets() -> list[WarmupTarget]:
|
|
36
|
+
from classifier_local import MODEL_ID, MODEL_REVISION
|
|
37
|
+
|
|
38
|
+
return [
|
|
39
|
+
WarmupTarget(
|
|
40
|
+
name="local-zero-shot-classifier",
|
|
41
|
+
kind="transformers_sequence_classifier",
|
|
42
|
+
model_id=MODEL_ID,
|
|
43
|
+
revision=MODEL_REVISION,
|
|
44
|
+
source="src/classifier_local.py",
|
|
45
|
+
),
|
|
46
|
+
WarmupTarget(
|
|
47
|
+
name="bge-base-embeddings",
|
|
48
|
+
kind="fastembed_embedding",
|
|
49
|
+
model_id="BAAI/bge-base-en-v1.5",
|
|
50
|
+
source="src/cognitive/_core.py",
|
|
51
|
+
),
|
|
52
|
+
WarmupTarget(
|
|
53
|
+
name="bge-small-embeddings",
|
|
54
|
+
kind="fastembed_embedding",
|
|
55
|
+
model_id="BAAI/bge-small-en-v1.5",
|
|
56
|
+
source="src/migrate_embeddings.py",
|
|
57
|
+
),
|
|
58
|
+
WarmupTarget(
|
|
59
|
+
name="cross-encoder-reranker",
|
|
60
|
+
kind="fastembed_reranker",
|
|
61
|
+
model_id="Xenova/ms-marco-MiniLM-L-6-v2",
|
|
62
|
+
source="src/cognitive/_core.py",
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _state_path() -> Path:
|
|
68
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", "~/.nexo")).expanduser()
|
|
69
|
+
return nexo_home / "runtime" / "operations" / "model-warmup-state.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_state(payload: dict[str, Any]) -> None:
|
|
73
|
+
path = _state_path()
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _warm_transformers(target: WarmupTarget) -> None:
|
|
79
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
80
|
+
|
|
81
|
+
kwargs: dict[str, str] = {}
|
|
82
|
+
if target.revision:
|
|
83
|
+
kwargs["revision"] = target.revision
|
|
84
|
+
AutoTokenizer.from_pretrained(target.model_id, **kwargs)
|
|
85
|
+
AutoModelForSequenceClassification.from_pretrained(target.model_id, **kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _warm_fastembed_embedding(target: WarmupTarget) -> None:
|
|
89
|
+
from fastembed import TextEmbedding
|
|
90
|
+
|
|
91
|
+
model = TextEmbedding(target.model_id)
|
|
92
|
+
list(model.embed(["NEXO model warmup"]))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _warm_fastembed_reranker(target: WarmupTarget) -> None:
|
|
96
|
+
from fastembed.rerank.cross_encoder import TextCrossEncoder
|
|
97
|
+
|
|
98
|
+
TextCrossEncoder(target.model_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def warm_target(target: WarmupTarget) -> None:
|
|
102
|
+
if target.kind == "transformers_sequence_classifier":
|
|
103
|
+
_warm_transformers(target)
|
|
104
|
+
return
|
|
105
|
+
if target.kind == "fastembed_embedding":
|
|
106
|
+
_warm_fastembed_embedding(target)
|
|
107
|
+
return
|
|
108
|
+
if target.kind == "fastembed_reranker":
|
|
109
|
+
_warm_fastembed_reranker(target)
|
|
110
|
+
return
|
|
111
|
+
raise ValueError(f"unknown warmup target kind: {target.kind}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def target_to_json(target: WarmupTarget) -> dict[str, Any]:
|
|
115
|
+
return {key: value for key, value in asdict(target).items() if value is not None}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run(args: argparse.Namespace) -> int:
|
|
119
|
+
targets = warmup_targets()
|
|
120
|
+
if args.dry_run:
|
|
121
|
+
payload = {
|
|
122
|
+
"ok": True,
|
|
123
|
+
"dry_run": True,
|
|
124
|
+
"targets": [target_to_json(target) for target in targets],
|
|
125
|
+
}
|
|
126
|
+
if args.json:
|
|
127
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
128
|
+
else:
|
|
129
|
+
for target in targets:
|
|
130
|
+
revision = f"@{target.revision}" if target.revision else ""
|
|
131
|
+
print(f"{target.name}: {target.model_id}{revision}")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
started = time.time()
|
|
135
|
+
results: list[dict[str, Any]] = []
|
|
136
|
+
ok = True
|
|
137
|
+
for target in targets:
|
|
138
|
+
item = target_to_json(target)
|
|
139
|
+
item["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
140
|
+
try:
|
|
141
|
+
if not args.json:
|
|
142
|
+
revision = f"@{target.revision}" if target.revision else ""
|
|
143
|
+
print(f"[model-warmup] {target.name}: {target.model_id}{revision}", flush=True)
|
|
144
|
+
warm_target(target)
|
|
145
|
+
item["ok"] = True
|
|
146
|
+
except Exception as exc: # pragma: no cover - depends on host/network/cache
|
|
147
|
+
item["ok"] = False
|
|
148
|
+
item["error"] = str(exc)
|
|
149
|
+
if target.required:
|
|
150
|
+
ok = False
|
|
151
|
+
if not args.json:
|
|
152
|
+
print(f"[model-warmup] FAILED {target.name}: {exc}", file=sys.stderr, flush=True)
|
|
153
|
+
item["finished_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
154
|
+
results.append(item)
|
|
155
|
+
|
|
156
|
+
payload = {
|
|
157
|
+
"ok": ok,
|
|
158
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started)),
|
|
159
|
+
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
160
|
+
"targets": results,
|
|
161
|
+
}
|
|
162
|
+
_write_state(payload)
|
|
163
|
+
if args.json:
|
|
164
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
165
|
+
return 0 if ok or not args.strict else 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> int:
|
|
169
|
+
parser = argparse.ArgumentParser(description="Predownload NEXO local model weights.")
|
|
170
|
+
parser.add_argument("--dry-run", action="store_true", help="List model targets without importing ML dependencies.")
|
|
171
|
+
parser.add_argument("--json", action="store_true", help="Print JSON output.")
|
|
172
|
+
parser.add_argument("--strict", action="store_true", help="Exit non-zero if any required model fails.")
|
|
173
|
+
return run(parser.parse_args())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
raise SystemExit(main())
|
|
@@ -147,6 +147,27 @@ def handle_nexo_lifecycle_complete_canonical(
|
|
|
147
147
|
return json.dumps(ack, ensure_ascii=False)
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def handle_nexo_lifecycle_wait_for_diary(
|
|
151
|
+
event_id: str,
|
|
152
|
+
timeout_ms: int = 45_000,
|
|
153
|
+
poll_ms: int = 500,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Wait until a canonical lifecycle event has real diary evidence."""
|
|
156
|
+
try:
|
|
157
|
+
ack = lifecycle_events.wait_for_canonical_diary(
|
|
158
|
+
event_id=str(event_id or ""),
|
|
159
|
+
timeout_ms=int(timeout_ms or 0),
|
|
160
|
+
poll_ms=int(poll_ms or 500),
|
|
161
|
+
)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
return json.dumps({
|
|
164
|
+
"status": "retryable_error",
|
|
165
|
+
"reason": f"{type(exc).__name__}: {exc}",
|
|
166
|
+
"handler_threw": True,
|
|
167
|
+
}, ensure_ascii=False)
|
|
168
|
+
return json.dumps(ack, ensure_ascii=False)
|
|
169
|
+
|
|
170
|
+
|
|
150
171
|
TOOLS = [
|
|
151
172
|
(
|
|
152
173
|
handle_nexo_lifecycle_event,
|
|
@@ -163,4 +184,9 @@ TOOLS = [
|
|
|
163
184
|
"nexo_lifecycle_complete_canonical",
|
|
164
185
|
"Confirm that Desktop finished executing the canonical_actions Brain handed out in a prior nexo_lifecycle_event call. Brain marks canonical_done_at only on this confirmation.",
|
|
165
186
|
),
|
|
187
|
+
(
|
|
188
|
+
handle_nexo_lifecycle_wait_for_diary,
|
|
189
|
+
"nexo_lifecycle_wait_for_diary",
|
|
190
|
+
"Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
|
|
191
|
+
),
|
|
166
192
|
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-claude-md-version: 2.1.
|
|
1
|
+
<!-- nexo-claude-md-version: 2.1.6 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — Cognitive Co-Operator
|
|
@@ -20,6 +20,16 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
|
|
|
20
20
|
**Presentation:** {{NAME}} speaks first. Conversational greeting adapted to time of day. Tell what you HAVE DONE, not list pending items. Menu only if the user asks.
|
|
21
21
|
<!-- nexo:end:startup -->
|
|
22
22
|
|
|
23
|
+
<!-- nexo:start:tools_at_startup -->
|
|
24
|
+
## Tools availability at startup
|
|
25
|
+
Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name surfaced, schema not yet loaded). Deferred ≠ unavailable.
|
|
26
|
+
|
|
27
|
+
- If a needed `mcp__nexo__*` tool is not directly callable, run `ToolSearch` with `select:mcp__nexo__<name>[,<name>...]` to load its schema — BEFORE reporting a blocked session or missing capability.
|
|
28
|
+
- If `nexo_startup` itself appears deferred, load it via ToolSearch first — never start a session without a real SID.
|
|
29
|
+
- If ToolSearch still cannot resolve a `nexo_*` tool, then (and only then) treat it as a real runtime gap and surface it as a blocker.
|
|
30
|
+
- Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
|
|
31
|
+
<!-- nexo:end:tools_at_startup -->
|
|
32
|
+
|
|
23
33
|
## Protocol (6 rules)
|
|
24
34
|
1. `nexo_startup` once per session and keep the returned `SID`.
|
|
25
35
|
2. `nexo_heartbeat` on every user message.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-codex-agents-version: 1.2.
|
|
1
|
+
<!-- nexo-codex-agents-version: 1.2.5 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — NEXO Shared Brain for Codex
|
|
@@ -17,6 +17,14 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
|
|
|
17
17
|
4. Execute overdue followups silently when appropriate.
|
|
18
18
|
5. Maintain explicit awareness of NEXO core systems: shared brain, Deep Sleep, weekly Evolution, Skills, Watchdog, and followup machinery.
|
|
19
19
|
|
|
20
|
+
## Tools availability at startup
|
|
21
|
+
Codex (and Claude Code) may list `mcp__nexo__*` tools as **deferred** at session start (name surfaced, schema not yet loaded). Deferred ≠ unavailable.
|
|
22
|
+
|
|
23
|
+
- If a needed `mcp__nexo__*` tool is not directly callable, resolve its schema via the client's tool-discovery path (Claude Code: `ToolSearch` with `select:mcp__nexo__<name>`) BEFORE reporting a blocked session or missing capability.
|
|
24
|
+
- If `nexo_startup` itself appears deferred, load it first — never start a session without a real SID.
|
|
25
|
+
- If discovery still cannot resolve a `nexo_*` tool, then (and only then) treat it as a real runtime gap and surface it as a blocker.
|
|
26
|
+
- Diagnostic plane: `nexo_doctor plane='installation_live'` inspects client/install surfaces — consult it when tools appear missing on a fresh install.
|
|
27
|
+
|
|
20
28
|
## Protocol (6 rules)
|
|
21
29
|
1. `nexo_startup` once per session, then keep the returned `SID`.
|
|
22
30
|
2. `nexo_heartbeat` on every user message.
|
|
@@ -1882,6 +1882,19 @@
|
|
|
1882
1882
|
},
|
|
1883
1883
|
"triggers_after": []
|
|
1884
1884
|
},
|
|
1885
|
+
"nexo_lifecycle_wait_for_diary": {
|
|
1886
|
+
"description": "Wait for real session_diary evidence for a canonical lifecycle event before stop_session is allowed.",
|
|
1887
|
+
"category": "lifecycle",
|
|
1888
|
+
"source": "plugin:lifecycle_events",
|
|
1889
|
+
"requires": [],
|
|
1890
|
+
"provides": [],
|
|
1891
|
+
"internal_calls": [],
|
|
1892
|
+
"enforcement": {
|
|
1893
|
+
"level": "none",
|
|
1894
|
+
"rules": []
|
|
1895
|
+
},
|
|
1896
|
+
"triggers_after": []
|
|
1897
|
+
},
|
|
1885
1898
|
"nexo_media_memory_add": {
|
|
1886
1899
|
"description": "Store non-text artifact metadata",
|
|
1887
1900
|
"category": "media",
|