nexo-brain 7.13.8 → 7.14.0
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 +5 -1
- package/bin/nexo-brain.js +80 -17
- package/bin/windows-wsl-bridge.js +0 -0
- package/package.json +1 -1
- package/src/enforcement_engine.py +58 -0
- package/src/hook_guardrails.py +76 -0
- package/src/memory_layer_audit.py +243 -0
- package/src/plugins/protocol.py +169 -0
- package/src/scripts/nexo-followup-hygiene.py +25 -6
- package/src/scripts/nexo-followup-runner.py +83 -7
- package/src/tools_sessions.py +12 -0
- package/templates/CLAUDE.md.template +2 -1
- package/templates/CODEX.AGENTS.md.template +6 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.14.0",
|
|
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,11 @@
|
|
|
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.
|
|
21
|
+
Version `7.14.0` is the current packaged-runtime line. Minor release over v7.13.9 — Brain closes the install/reliability loop with update-path venv recovery, platform-gated wheels, WSL Desktop-managed flag preservation, startup memory authority warnings, legacy MEMORY write blocking, post-action real-world verification, and stale followup triage.
|
|
22
|
+
|
|
23
|
+
Previously in `7.13.9`: patch release — Brain moves aside an existing managed `.venv` when it was created with unsupported Python <3.10, then recreates it with the supported interpreter prepared by Desktop.
|
|
24
|
+
|
|
25
|
+
Previously in `7.13.8`: patch release — Brain rejects Python <3.10 during Desktop-managed fresh installs, honors the Python interpreter prepared by Desktop, and fails clearly before dependency resolution if an unsupported Apple Python 3.9 reaches the installer.
|
|
22
26
|
|
|
23
27
|
Previously in `7.13.7`: patch release — Brain adds an authenticated official protocol-card client (`nexo_card_catalog`, `nexo_card_get`, `nexo_card_match`) so agents can ask the NEXO Desktop backend for the right task protocol at runtime. The protocol corpus stays private on the server; this open-source package ships only the client, tool map, and agent guidance.
|
|
24
28
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -291,6 +291,23 @@ function findBundledWheel(wheelsDir, prefix) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
function bundledWheelsSupportCurrentPlatform(wheelsDir) {
|
|
295
|
+
if (!fs.existsSync(wheelsDir)) return false;
|
|
296
|
+
if (process.platform === "linux") return true;
|
|
297
|
+
if (process.platform !== "darwin") return false;
|
|
298
|
+
try {
|
|
299
|
+
const names = fs.readdirSync(wheelsDir).map((name) => String(name || "").toLowerCase());
|
|
300
|
+
const archTag = process.arch === "arm64" ? "arm64" : "x86_64";
|
|
301
|
+
return names.some((name) => (
|
|
302
|
+
name.endsWith(".whl")
|
|
303
|
+
&& name.includes("macosx")
|
|
304
|
+
&& (name.includes("universal2") || name.includes(archTag))
|
|
305
|
+
));
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
294
311
|
function pythonHasPip(pythonBin) {
|
|
295
312
|
try {
|
|
296
313
|
const result = spawnSync(pythonBin, ["-m", "pip", "--version"], {
|
|
@@ -303,6 +320,46 @@ function pythonHasPip(pythonBin) {
|
|
|
303
320
|
}
|
|
304
321
|
}
|
|
305
322
|
|
|
323
|
+
function managedVenvPythonPath(nexoHome = NEXO_HOME) {
|
|
324
|
+
const venvPath = path.join(nexoHome, ".venv");
|
|
325
|
+
return process.platform === "win32"
|
|
326
|
+
? path.join(venvPath, "Scripts", "python.exe")
|
|
327
|
+
: path.join(venvPath, "bin", "python3");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function safeTimestampForPath() {
|
|
331
|
+
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function uniqueBackupPath(targetPath, suffix) {
|
|
335
|
+
const dir = path.dirname(targetPath);
|
|
336
|
+
const base = path.basename(targetPath);
|
|
337
|
+
const stamp = safeTimestampForPath();
|
|
338
|
+
let candidate = path.join(dir, `${base}.${suffix}-${stamp}`);
|
|
339
|
+
if (!fs.existsSync(candidate)) return candidate;
|
|
340
|
+
for (let i = 2; i < 100; i += 1) {
|
|
341
|
+
candidate = path.join(dir, `${base}.${suffix}-${stamp}-${i}`);
|
|
342
|
+
if (!fs.existsSync(candidate)) return candidate;
|
|
343
|
+
}
|
|
344
|
+
return path.join(dir, `${base}.${suffix}-${stamp}-${process.pid}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function ensureManagedVenvCompatible(venvPath, venvPython) {
|
|
348
|
+
if (!fs.existsSync(venvPython)) return;
|
|
349
|
+
const version = pythonVersion(venvPython);
|
|
350
|
+
if (version && pythonVersionMeetsMinimum(version)) return;
|
|
351
|
+
|
|
352
|
+
const reason = version ? `Python ${version}` : "an unreadable Python executable";
|
|
353
|
+
const backupPath = uniqueBackupPath(venvPath, "unsupported-python");
|
|
354
|
+
log(` Existing Python virtual environment uses ${reason}; moving it aside to recreate.`);
|
|
355
|
+
try {
|
|
356
|
+
fs.renameSync(venvPath, backupPath);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
throw new Error(`Existing NEXO Python virtual environment is incompatible and could not be moved aside: ${err.message || err}`);
|
|
359
|
+
}
|
|
360
|
+
log(` Previous Python virtual environment moved to ${backupPath}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
306
363
|
function seedPipFromBundledWheels(venvPython, bundledWheelsDir) {
|
|
307
364
|
if (!fs.existsSync(venvPython) || !fs.existsSync(bundledWheelsDir)) return false;
|
|
308
365
|
if (pythonHasPip(venvPython)) return true;
|
|
@@ -576,15 +633,13 @@ function resolveSystemPython() {
|
|
|
576
633
|
}
|
|
577
634
|
|
|
578
635
|
function ensureWarmupPython(nexoHome = NEXO_HOME) {
|
|
579
|
-
const existing = findVenvPython(nexoHome);
|
|
580
|
-
if (existing) return existing;
|
|
581
|
-
|
|
582
|
-
const basePython = resolveSystemPython();
|
|
583
636
|
const venvPath = path.join(nexoHome, ".venv");
|
|
584
|
-
const venvPython =
|
|
585
|
-
? path.join(venvPath, "Scripts", "python.exe")
|
|
586
|
-
: path.join(venvPath, "bin", "python3");
|
|
637
|
+
const venvPython = managedVenvPythonPath(nexoHome);
|
|
587
638
|
fs.mkdirSync(nexoHome, { recursive: true });
|
|
639
|
+
ensureManagedVenvCompatible(venvPath, venvPython);
|
|
640
|
+
if (fs.existsSync(venvPython)) return venvPython;
|
|
641
|
+
|
|
642
|
+
const basePython = resolveInstallerPython() || resolveSystemPython();
|
|
588
643
|
if (!fs.existsSync(venvPython)) {
|
|
589
644
|
log(" Creating Python virtual environment for model warmup...");
|
|
590
645
|
const result = spawnSync(basePython, ["-m", "venv", venvPath], { stdio: "inherit", timeout: 120000 });
|
|
@@ -2398,7 +2453,9 @@ async function maybeConfigurePublicContribution(schedule, useDefaults) {
|
|
|
2398
2453
|
* Resolve the venv python path for an existing NEXO_HOME installation.
|
|
2399
2454
|
*/
|
|
2400
2455
|
function findVenvPython(nexoHome) {
|
|
2401
|
-
const
|
|
2456
|
+
const venvPath = path.join(nexoHome, ".venv");
|
|
2457
|
+
const venvPy = managedVenvPythonPath(nexoHome);
|
|
2458
|
+
ensureManagedVenvCompatible(venvPath, venvPy);
|
|
2402
2459
|
if (fs.existsSync(venvPy)) return venvPy;
|
|
2403
2460
|
return null;
|
|
2404
2461
|
}
|
|
@@ -3702,11 +3759,11 @@ async function runSetup() {
|
|
|
3702
3759
|
log("Installing cognitive engine dependencies...");
|
|
3703
3760
|
fs.mkdirSync(NEXO_HOME, { recursive: true });
|
|
3704
3761
|
const venvPath = path.join(NEXO_HOME, ".venv");
|
|
3705
|
-
const venvPython =
|
|
3706
|
-
? path.join(venvPath, "Scripts", "python.exe")
|
|
3707
|
-
: path.join(venvPath, "bin", "python3");
|
|
3762
|
+
const venvPython = managedVenvPythonPath(NEXO_HOME);
|
|
3708
3763
|
const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
|
|
3709
3764
|
|
|
3765
|
+
ensureManagedVenvCompatible(venvPath, venvPython);
|
|
3766
|
+
|
|
3710
3767
|
// Create venv if it doesn't exist
|
|
3711
3768
|
if (!fs.existsSync(venvPython)) {
|
|
3712
3769
|
log(" Creating Python virtual environment...");
|
|
@@ -3724,6 +3781,13 @@ async function runSetup() {
|
|
|
3724
3781
|
}
|
|
3725
3782
|
}
|
|
3726
3783
|
}
|
|
3784
|
+
if (fs.existsSync(venvPython)) {
|
|
3785
|
+
const venvVersion = pythonVersion(venvPython);
|
|
3786
|
+
if (!venvVersion || !pythonVersionMeetsMinimum(venvVersion)) {
|
|
3787
|
+
log(`Python virtual environment is unsupported after creation (${venvVersion || "unknown version"}).`);
|
|
3788
|
+
process.exit(1);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3727
3791
|
if (fs.existsSync(venvPython) && !pythonHasPip(venvPython)) {
|
|
3728
3792
|
seedPipFromBundledWheels(venvPython, bundledWheelsDir);
|
|
3729
3793
|
}
|
|
@@ -3734,12 +3798,11 @@ async function runSetup() {
|
|
|
3734
3798
|
// Detect bundled wheels in resources/python-wheels (offline-first). If
|
|
3735
3799
|
// present, pip uses --no-index --find-links to install without internet.
|
|
3736
3800
|
// Falls back to PyPI if bundle not found.
|
|
3737
|
-
//
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
|
|
3742
|
-
const useBundle = process.platform === "linux" && fs.existsSync(bundledWheelsDir);
|
|
3801
|
+
// Desktop bundles Linux/WSL wheels and, from 0.32.44, macOS arm64/x64
|
|
3802
|
+
// wheels. Only use --no-index when the bundle clearly contains wheels
|
|
3803
|
+
// compatible with the current runtime; otherwise fall back to PyPI
|
|
3804
|
+
// instead of failing on ABI-mismatched wheels.
|
|
3805
|
+
const useBundle = bundledWheelsSupportCurrentPlatform(bundledWheelsDir);
|
|
3743
3806
|
const pipArgs = useBundle
|
|
3744
3807
|
? ["-m", "pip", "install", "--no-index", "--find-links", bundledWheelsDir, "--progress-bar", "off", "-r", requirementsFile]
|
|
3745
3808
|
: ["-m", "pip", "install", "-v", "--progress-bar", "off", "--default-timeout=60", "-r", requirementsFile];
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.14.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -1732,6 +1732,63 @@ class HeadlessEnforcer:
|
|
|
1732
1732
|
self._enqueue(prompt, "R23m_message_duplicate", rule_id="R23m_message_duplicate")
|
|
1733
1733
|
_logger.info("[R23m %s] enqueued", mode.upper())
|
|
1734
1734
|
|
|
1735
|
+
def _check_post_external_action_verification(self, tool_name: str, tool_input):
|
|
1736
|
+
"""Require an explicit re-open/re-read step after outbound actions."""
|
|
1737
|
+
external_tools = {
|
|
1738
|
+
"nexo_send",
|
|
1739
|
+
"nexo_email_send",
|
|
1740
|
+
"gmail_send",
|
|
1741
|
+
"nexo_calendar_create",
|
|
1742
|
+
"nexo_calendar_update",
|
|
1743
|
+
"google_calendar_create",
|
|
1744
|
+
"google_calendar_update",
|
|
1745
|
+
"calendar_create",
|
|
1746
|
+
"calendar_update",
|
|
1747
|
+
}
|
|
1748
|
+
if tool_name not in external_tools:
|
|
1749
|
+
return
|
|
1750
|
+
target = ""
|
|
1751
|
+
if isinstance(tool_input, dict):
|
|
1752
|
+
target = str(
|
|
1753
|
+
tool_input.get("to")
|
|
1754
|
+
or tool_input.get("recipient")
|
|
1755
|
+
or tool_input.get("thread")
|
|
1756
|
+
or tool_input.get("title")
|
|
1757
|
+
or tool_input.get("summary")
|
|
1758
|
+
or ""
|
|
1759
|
+
).strip()
|
|
1760
|
+
prompt = (
|
|
1761
|
+
f"You just performed an external action with `{tool_name}`"
|
|
1762
|
+
f"{(' for ' + target[:120]) if target else ''}. "
|
|
1763
|
+
"Before you report it as sent or finished, reopen the real sent message/calendar item "
|
|
1764
|
+
"and verify the external facts: recipients, CC/BCC, subject, body/signature, "
|
|
1765
|
+
"date/time/timezone, links, invitees, attachments, and any identity/location claims. "
|
|
1766
|
+
"If anything is wrong, fix it first; otherwise include that verification in the closure evidence."
|
|
1767
|
+
)
|
|
1768
|
+
self._enqueue(
|
|
1769
|
+
prompt,
|
|
1770
|
+
f"post-action-verify:{tool_name}:{self.tool_call_count}",
|
|
1771
|
+
rule_id="R23n_post_action_verification",
|
|
1772
|
+
)
|
|
1773
|
+
if self._session_id:
|
|
1774
|
+
try:
|
|
1775
|
+
from db import create_protocol_debt, list_protocol_tasks # type: ignore
|
|
1776
|
+
|
|
1777
|
+
tasks = list_protocol_tasks(status="open", session_id=self._session_id, limit=1)
|
|
1778
|
+
task_id = str((tasks[0] if tasks else {}).get("task_id") or "")
|
|
1779
|
+
create_protocol_debt(
|
|
1780
|
+
self._session_id,
|
|
1781
|
+
"post_external_action_verification_required",
|
|
1782
|
+
severity="warn",
|
|
1783
|
+
task_id=task_id,
|
|
1784
|
+
evidence=(
|
|
1785
|
+
f"{tool_name} was called for an external action. The agent must reopen/re-read "
|
|
1786
|
+
"the real sent/event artifact before claiming completion."
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
except Exception:
|
|
1790
|
+
pass
|
|
1791
|
+
|
|
1735
1792
|
def _check_r23h(self, tool_name: str, tool_input):
|
|
1736
1793
|
"""R23h — script shebang vs interpreter mismatch (Fase D2 shadow)."""
|
|
1737
1794
|
if _r23h_should is None:
|
|
@@ -2170,6 +2227,7 @@ class HeadlessEnforcer:
|
|
|
2170
2227
|
self._check_r23i(name, tool_input)
|
|
2171
2228
|
self._check_r23k(name, tool_input)
|
|
2172
2229
|
self._check_r23m(name, tool_input)
|
|
2230
|
+
self._check_post_external_action_verification(name, tool_input)
|
|
2173
2231
|
|
|
2174
2232
|
# D2 shadow rules — low-signal, rolling out carefully.
|
|
2175
2233
|
self._check_r23h(name, tool_input)
|
package/src/hook_guardrails.py
CHANGED
|
@@ -524,6 +524,59 @@ def _is_live_repo_path(path: str) -> bool:
|
|
|
524
524
|
return False
|
|
525
525
|
|
|
526
526
|
|
|
527
|
+
def _legacy_memory_write_allowed() -> bool:
|
|
528
|
+
return os.environ.get("NEXO_ALLOW_LEGACY_MEMORY_WRITE", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _is_legacy_client_memory_path(path_value: str) -> bool:
|
|
532
|
+
if not str(path_value or "").strip():
|
|
533
|
+
return False
|
|
534
|
+
try:
|
|
535
|
+
candidate = _resolve_runtime_path(path_value)
|
|
536
|
+
home = Path.home().resolve(strict=False)
|
|
537
|
+
relative = candidate.relative_to(home)
|
|
538
|
+
except Exception:
|
|
539
|
+
return False
|
|
540
|
+
parts = relative.parts
|
|
541
|
+
if len(parts) == 2 and parts[0] in {".claude", ".codex"} and parts[1] == "MEMORY.md":
|
|
542
|
+
return True
|
|
543
|
+
if len(parts) >= 2 and parts[0] in {".claude", ".codex"} and parts[1] == "memories":
|
|
544
|
+
return True
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _collect_legacy_memory_write_blocks(conn, *, sid: str, task: dict | None, tool_name: str, files: list[str]) -> list[dict]:
|
|
549
|
+
if _legacy_memory_write_allowed():
|
|
550
|
+
return []
|
|
551
|
+
blocks: list[dict] = []
|
|
552
|
+
task_id = str((task or {}).get("task_id") or "").strip()
|
|
553
|
+
for filepath in files:
|
|
554
|
+
if not _is_legacy_client_memory_path(filepath):
|
|
555
|
+
continue
|
|
556
|
+
debt = _ensure_protocol_debt(
|
|
557
|
+
conn,
|
|
558
|
+
session_id=sid,
|
|
559
|
+
task_id=task_id,
|
|
560
|
+
debt_type="legacy_client_memory_write_blocked",
|
|
561
|
+
severity="error",
|
|
562
|
+
evidence=(
|
|
563
|
+
f"{tool_name} attempted to write {filepath}. Legacy Claude/Codex "
|
|
564
|
+
"MEMORY files are not durable NEXO Brain state and must not be "
|
|
565
|
+
"updated by agents. Use NEXO Brain memory/profile/calibration APIs instead."
|
|
566
|
+
),
|
|
567
|
+
file_token=filepath,
|
|
568
|
+
)
|
|
569
|
+
blocks.append({
|
|
570
|
+
"file": filepath,
|
|
571
|
+
"task_id": task_id,
|
|
572
|
+
"debt_id": debt.get("id"),
|
|
573
|
+
"debt_type": "legacy_client_memory_write_blocked",
|
|
574
|
+
"reason_code": "legacy_client_memory_protected",
|
|
575
|
+
"severity": "error",
|
|
576
|
+
})
|
|
577
|
+
return blocks
|
|
578
|
+
|
|
579
|
+
|
|
527
580
|
def _extract_touched_files(tool_input) -> list[str]:
|
|
528
581
|
files: list[str] = []
|
|
529
582
|
if not isinstance(tool_input, dict):
|
|
@@ -1481,6 +1534,24 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1481
1534
|
sid = _resolve_nexo_sid(conn, claude_sid)
|
|
1482
1535
|
open_task = _find_any_open_task(conn, sid) if sid else None
|
|
1483
1536
|
warnings: list[dict] = []
|
|
1537
|
+
legacy_memory_blocks = _collect_legacy_memory_write_blocks(
|
|
1538
|
+
conn,
|
|
1539
|
+
sid=sid,
|
|
1540
|
+
task=open_task,
|
|
1541
|
+
tool_name=tool_name,
|
|
1542
|
+
files=files,
|
|
1543
|
+
)
|
|
1544
|
+
if legacy_memory_blocks:
|
|
1545
|
+
return {
|
|
1546
|
+
"ok": True,
|
|
1547
|
+
"session_id": sid,
|
|
1548
|
+
"tool_name": tool_name,
|
|
1549
|
+
"operation": op,
|
|
1550
|
+
"strictness": strictness,
|
|
1551
|
+
"blocks": legacy_memory_blocks,
|
|
1552
|
+
"warnings": warnings,
|
|
1553
|
+
"status": "blocked",
|
|
1554
|
+
}
|
|
1484
1555
|
if tool_name == "Bash":
|
|
1485
1556
|
launchagent_operation_warnings = _collect_launchagent_operation_warnings(
|
|
1486
1557
|
conn,
|
|
@@ -2113,6 +2184,11 @@ def format_pretool_block_message(result: dict) -> str:
|
|
|
2113
2184
|
f"- {file_note}: `~/.nexo/core` is a protected install surface. "
|
|
2114
2185
|
"Route the change through the source repo + release/update flow instead of editing the live installed core."
|
|
2115
2186
|
)
|
|
2187
|
+
elif item.get("reason_code") == "legacy_client_memory_protected":
|
|
2188
|
+
lines.append(
|
|
2189
|
+
f"- {file_note}: legacy Claude/Codex MEMORY files are read-only in NEXO. "
|
|
2190
|
+
"Use NEXO Brain profile/calibration/memory tools instead."
|
|
2191
|
+
)
|
|
2116
2192
|
elif item.get("reason_code") == "guard_unacknowledged":
|
|
2117
2193
|
lines.append(
|
|
2118
2194
|
f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Startup audit for client memory layers.
|
|
2
|
+
|
|
3
|
+
NEXO Brain is the durable memory authority. Client-local MEMORY files can
|
|
4
|
+
exist from older Claude/Codex installs, but they must not silently override
|
|
5
|
+
calibration/profile data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import paths
|
|
17
|
+
|
|
18
|
+
LEGACY_MEMORY_PATHS = (
|
|
19
|
+
(".claude", "MEMORY.md"),
|
|
20
|
+
(".codex", "MEMORY.md"),
|
|
21
|
+
)
|
|
22
|
+
LEGACY_MEMORY_DIRS = (
|
|
23
|
+
(".claude", "memories"),
|
|
24
|
+
(".codex", "memories"),
|
|
25
|
+
)
|
|
26
|
+
BOOTSTRAP_PATHS = (
|
|
27
|
+
(".claude", "CLAUDE.md"),
|
|
28
|
+
(".codex", "AGENTS.md"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
AUTHORITY_ORDER = [
|
|
32
|
+
"brain/calibration.json",
|
|
33
|
+
"brain/profile.json",
|
|
34
|
+
"NEXO Brain DB: followups, learnings, decisions, diary, outcomes",
|
|
35
|
+
"managed client bootstrap CORE blocks",
|
|
36
|
+
"legacy client MEMORY.md files (read-only, lowest authority)",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
LOCATION_HINT_RE = re.compile(
|
|
40
|
+
r"\b("
|
|
41
|
+
r"lives?|resides?|resident|residence|location|city|from|based|"
|
|
42
|
+
r"vive|reside|residencia|ubicaci[oó]n|ciudad|de\s+"
|
|
43
|
+
r")\b",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
TOKEN_RE = re.compile(r"[A-Za-z0-9_ÁÉÍÓÚÜÑáéíóúüñ]+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
50
|
+
try:
|
|
51
|
+
if path.exists():
|
|
52
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
return payload
|
|
55
|
+
except Exception:
|
|
56
|
+
return {}
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _brain_dir_candidates(nexo_home: Path) -> list[Path]:
|
|
61
|
+
candidates = [nexo_home / "brain", nexo_home / "personal" / "brain"]
|
|
62
|
+
try:
|
|
63
|
+
current = paths.brain_dir()
|
|
64
|
+
if current not in candidates:
|
|
65
|
+
candidates.append(current)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
unique: list[Path] = []
|
|
69
|
+
seen = set()
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
key = str(candidate)
|
|
72
|
+
if key not in seen:
|
|
73
|
+
seen.add(key)
|
|
74
|
+
unique.append(candidate)
|
|
75
|
+
return unique
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_canonical_sources(nexo_home: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
|
|
79
|
+
for brain_dir in _brain_dir_candidates(nexo_home):
|
|
80
|
+
calibration = _read_json(brain_dir / "calibration.json")
|
|
81
|
+
profile = _read_json(brain_dir / "profile.json")
|
|
82
|
+
if calibration or profile:
|
|
83
|
+
return calibration, profile, {
|
|
84
|
+
"calibration": str(brain_dir / "calibration.json"),
|
|
85
|
+
"profile": str(brain_dir / "profile.json"),
|
|
86
|
+
}
|
|
87
|
+
fallback = _brain_dir_candidates(nexo_home)[0]
|
|
88
|
+
return {}, {}, {
|
|
89
|
+
"calibration": str(fallback / "calibration.json"),
|
|
90
|
+
"profile": str(fallback / "profile.json"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _iter_strings(value: Any) -> list[str]:
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
return [value.strip()] if value.strip() else []
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
out: list[str] = []
|
|
99
|
+
for item in value.values():
|
|
100
|
+
out.extend(_iter_strings(item))
|
|
101
|
+
return out
|
|
102
|
+
if isinstance(value, list):
|
|
103
|
+
out: list[str] = []
|
|
104
|
+
for item in value:
|
|
105
|
+
out.extend(_iter_strings(item))
|
|
106
|
+
return out
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tokenize(text: str) -> set[str]:
|
|
111
|
+
return {
|
|
112
|
+
token.lower()
|
|
113
|
+
for token in TOKEN_RE.findall(text or "")
|
|
114
|
+
if len(token) >= 4
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _canonical_location_tokens(calibration: dict[str, Any], profile: dict[str, Any]) -> set[str]:
|
|
119
|
+
values: list[str] = []
|
|
120
|
+
for container in (profile, calibration):
|
|
121
|
+
for key in (
|
|
122
|
+
"current_residence",
|
|
123
|
+
"residence",
|
|
124
|
+
"location",
|
|
125
|
+
"base_location",
|
|
126
|
+
"city",
|
|
127
|
+
"country",
|
|
128
|
+
"timezone",
|
|
129
|
+
):
|
|
130
|
+
if key in container:
|
|
131
|
+
values.extend(_iter_strings(container.get(key)))
|
|
132
|
+
user = container.get("user")
|
|
133
|
+
if isinstance(user, dict):
|
|
134
|
+
for key in ("location", "city", "country", "timezone"):
|
|
135
|
+
if key in user:
|
|
136
|
+
values.extend(_iter_strings(user.get(key)))
|
|
137
|
+
tokens: set[str] = set()
|
|
138
|
+
for value in values:
|
|
139
|
+
tokens.update(_tokenize(value))
|
|
140
|
+
return tokens
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _legacy_memory_paths(home: Path) -> list[Path]:
|
|
144
|
+
found: list[Path] = []
|
|
145
|
+
for parts in LEGACY_MEMORY_PATHS:
|
|
146
|
+
candidate = home.joinpath(*parts)
|
|
147
|
+
if candidate.exists():
|
|
148
|
+
found.append(candidate)
|
|
149
|
+
for parts in LEGACY_MEMORY_DIRS:
|
|
150
|
+
candidate = home.joinpath(*parts)
|
|
151
|
+
if candidate.exists() and any(candidate.iterdir()):
|
|
152
|
+
found.append(candidate)
|
|
153
|
+
return found
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _location_like_lines(text: str) -> list[str]:
|
|
157
|
+
lines: list[str] = []
|
|
158
|
+
for raw in str(text or "").splitlines():
|
|
159
|
+
line = raw.strip()
|
|
160
|
+
if not line or len(line) > 500:
|
|
161
|
+
continue
|
|
162
|
+
if LOCATION_HINT_RE.search(line):
|
|
163
|
+
lines.append(line[:240])
|
|
164
|
+
return lines
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def audit_memory_layers(
|
|
168
|
+
*,
|
|
169
|
+
home: str | Path | None = None,
|
|
170
|
+
nexo_home: str | Path | None = None,
|
|
171
|
+
max_warnings: int = 5,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Return a read-only audit of memory authority and legacy client files."""
|
|
174
|
+
|
|
175
|
+
home_path = Path(home) if home is not None else Path.home()
|
|
176
|
+
nexo_home_path = Path(nexo_home) if nexo_home is not None else Path(os.environ.get("NEXO_HOME", str(home_path / ".nexo")))
|
|
177
|
+
calibration, profile, source_paths = _load_canonical_sources(nexo_home_path)
|
|
178
|
+
canonical_location_tokens = _canonical_location_tokens(calibration, profile)
|
|
179
|
+
|
|
180
|
+
warnings: list[dict[str, Any]] = []
|
|
181
|
+
legacy_paths = _legacy_memory_paths(home_path)
|
|
182
|
+
if legacy_paths:
|
|
183
|
+
warnings.append({
|
|
184
|
+
"type": "legacy_client_memory_present",
|
|
185
|
+
"severity": "warn",
|
|
186
|
+
"paths": [str(path) for path in legacy_paths],
|
|
187
|
+
"message": "Legacy Claude/Codex MEMORY files exist. They are lower authority than NEXO Brain and should stay read-only.",
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
for parts in BOOTSTRAP_PATHS:
|
|
191
|
+
candidate = home_path.joinpath(*parts)
|
|
192
|
+
try:
|
|
193
|
+
text = candidate.read_text(encoding="utf-8") if candidate.exists() else ""
|
|
194
|
+
except Exception:
|
|
195
|
+
text = ""
|
|
196
|
+
if not text:
|
|
197
|
+
continue
|
|
198
|
+
for line in _location_like_lines(text):
|
|
199
|
+
line_tokens = _tokenize(line)
|
|
200
|
+
if canonical_location_tokens and line_tokens.isdisjoint(canonical_location_tokens):
|
|
201
|
+
warnings.append({
|
|
202
|
+
"type": "possible_identity_location_conflict",
|
|
203
|
+
"severity": "warn",
|
|
204
|
+
"path": str(candidate),
|
|
205
|
+
"line": line,
|
|
206
|
+
"message": "Bootstrap contains a location-like profile fact that does not match canonical calibration/profile tokens.",
|
|
207
|
+
})
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"ok": True,
|
|
212
|
+
"authority_order": AUTHORITY_ORDER,
|
|
213
|
+
"canonical_sources": source_paths,
|
|
214
|
+
"legacy_paths": [str(path) for path in legacy_paths],
|
|
215
|
+
"warnings": warnings[:max(0, int(max_warnings or 0))],
|
|
216
|
+
"warning_count": len(warnings),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def format_memory_layer_warnings(report: dict[str, Any]) -> list[str]:
|
|
221
|
+
warnings = report.get("warnings") if isinstance(report, dict) else None
|
|
222
|
+
if not isinstance(warnings, list) or not warnings:
|
|
223
|
+
return []
|
|
224
|
+
lines = [
|
|
225
|
+
"NEXO Brain/calibration/profile are authoritative; legacy client MEMORY files are read-only and lowest priority.",
|
|
226
|
+
]
|
|
227
|
+
for warning in warnings:
|
|
228
|
+
if not isinstance(warning, dict):
|
|
229
|
+
continue
|
|
230
|
+
kind = warning.get("type") or "memory_layer_warning"
|
|
231
|
+
if kind == "legacy_client_memory_present":
|
|
232
|
+
paths_text = ", ".join(warning.get("paths") or [])
|
|
233
|
+
lines.append(f"Legacy MEMORY present: {paths_text}")
|
|
234
|
+
elif kind == "possible_identity_location_conflict":
|
|
235
|
+
lines.append(
|
|
236
|
+
"Possible profile conflict in "
|
|
237
|
+
f"{warning.get('path')}: {warning.get('line')}"
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
lines.append(str(warning.get("message") or kind))
|
|
241
|
+
if report.get("warning_count", 0) > len(warnings):
|
|
242
|
+
lines.append(f"{report['warning_count'] - len(warnings)} more memory-layer warning(s) omitted.")
|
|
243
|
+
return lines
|
package/src/plugins/protocol.py
CHANGED
|
@@ -80,6 +80,34 @@ def _is_trivial_evidence(text: str) -> tuple[bool, str]:
|
|
|
80
80
|
return False, ""
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _external_real_world_text(task: dict, *parts: str) -> str:
|
|
84
|
+
fields = [
|
|
85
|
+
task.get("goal", ""),
|
|
86
|
+
task.get("area", ""),
|
|
87
|
+
task.get("project_hint", ""),
|
|
88
|
+
task.get("context_hint", ""),
|
|
89
|
+
task.get("verification_step", ""),
|
|
90
|
+
]
|
|
91
|
+
fields.extend(part for part in parts if part)
|
|
92
|
+
return " ".join(str(part or "") for part in fields).lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _requires_external_real_world_check(task: dict, *parts: str) -> bool:
|
|
96
|
+
if str(task.get("task_type") or "").strip() not in ACTION_TASKS:
|
|
97
|
+
return False
|
|
98
|
+
text = _external_real_world_text(task, *parts)
|
|
99
|
+
return any(keyword in text for keyword in EXTERNAL_REAL_WORLD_ACTION_KEYWORDS)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _has_external_real_world_evidence(text: str) -> bool:
|
|
103
|
+
lowered = str(text or "").lower()
|
|
104
|
+
if _is_trivial_evidence(lowered)[0]:
|
|
105
|
+
return False
|
|
106
|
+
has_verify_verb = any(keyword in lowered for keyword in REAL_WORLD_VERIFICATION_VERBS)
|
|
107
|
+
has_artifact = any(keyword in lowered for keyword in REAL_WORLD_ARTIFACT_KEYWORDS)
|
|
108
|
+
return has_verify_verb and has_artifact
|
|
109
|
+
|
|
110
|
+
|
|
83
111
|
ACTION_TASKS = {"edit", "execute", "delegate"}
|
|
84
112
|
RESPONSE_TASKS = {"answer", "analyze"}
|
|
85
113
|
_GUARD_TOUCH_DEBT_TYPES = {
|
|
@@ -87,6 +115,93 @@ _GUARD_TOUCH_DEBT_TYPES = {
|
|
|
87
115
|
"conditioned_file_touch_without_guard_ack",
|
|
88
116
|
"write_without_file_guard_check",
|
|
89
117
|
}
|
|
118
|
+
EXTERNAL_REAL_WORLD_ACTION_KEYWORDS = {
|
|
119
|
+
"email",
|
|
120
|
+
"e-mail",
|
|
121
|
+
"gmail",
|
|
122
|
+
"correo",
|
|
123
|
+
"mail",
|
|
124
|
+
"message",
|
|
125
|
+
"mensaje",
|
|
126
|
+
"whatsapp",
|
|
127
|
+
"telegram",
|
|
128
|
+
"sms",
|
|
129
|
+
"calendar",
|
|
130
|
+
"calendario",
|
|
131
|
+
"event",
|
|
132
|
+
"evento",
|
|
133
|
+
"invite",
|
|
134
|
+
"invitation",
|
|
135
|
+
"invitacion",
|
|
136
|
+
"invitación",
|
|
137
|
+
"meet",
|
|
138
|
+
"zoom",
|
|
139
|
+
"booking",
|
|
140
|
+
"reserva",
|
|
141
|
+
"send",
|
|
142
|
+
"sent",
|
|
143
|
+
"enviar",
|
|
144
|
+
"enviado",
|
|
145
|
+
"enviada",
|
|
146
|
+
"client",
|
|
147
|
+
"cliente",
|
|
148
|
+
"family",
|
|
149
|
+
"familia",
|
|
150
|
+
}
|
|
151
|
+
REAL_WORLD_VERIFICATION_VERBS = {
|
|
152
|
+
"verified",
|
|
153
|
+
"verify",
|
|
154
|
+
"checked",
|
|
155
|
+
"rechecked",
|
|
156
|
+
"re-read",
|
|
157
|
+
"reread",
|
|
158
|
+
"opened",
|
|
159
|
+
"inspected",
|
|
160
|
+
"confirmed",
|
|
161
|
+
"verificado",
|
|
162
|
+
"verifique",
|
|
163
|
+
"verifiqué",
|
|
164
|
+
"comprobado",
|
|
165
|
+
"comprobe",
|
|
166
|
+
"comprobé",
|
|
167
|
+
"revisado",
|
|
168
|
+
"revise",
|
|
169
|
+
"revisé",
|
|
170
|
+
"abierto",
|
|
171
|
+
"abri",
|
|
172
|
+
"abrí",
|
|
173
|
+
"confirmado",
|
|
174
|
+
}
|
|
175
|
+
REAL_WORLD_ARTIFACT_KEYWORDS = {
|
|
176
|
+
"sent folder",
|
|
177
|
+
"sent item",
|
|
178
|
+
"message-id",
|
|
179
|
+
"email",
|
|
180
|
+
"correo",
|
|
181
|
+
"destinatario",
|
|
182
|
+
"recipient",
|
|
183
|
+
"cc",
|
|
184
|
+
"bcc",
|
|
185
|
+
"subject",
|
|
186
|
+
"asunto",
|
|
187
|
+
"body",
|
|
188
|
+
"cuerpo",
|
|
189
|
+
"firma",
|
|
190
|
+
"signature",
|
|
191
|
+
"calendar",
|
|
192
|
+
"calendario",
|
|
193
|
+
"event",
|
|
194
|
+
"evento",
|
|
195
|
+
"invitee",
|
|
196
|
+
"invitado",
|
|
197
|
+
"meet link",
|
|
198
|
+
"meet",
|
|
199
|
+
"zoom",
|
|
200
|
+
"booking",
|
|
201
|
+
"reserva",
|
|
202
|
+
"sent",
|
|
203
|
+
"enviado",
|
|
204
|
+
}
|
|
90
205
|
HIGH_STAKES_KEYWORDS = {
|
|
91
206
|
"medical",
|
|
92
207
|
"legal",
|
|
@@ -1462,6 +1577,60 @@ def handle_task_close(
|
|
|
1462
1577
|
indent=2,
|
|
1463
1578
|
)
|
|
1464
1579
|
|
|
1580
|
+
if clean_outcome == "done" and _requires_external_real_world_check(
|
|
1581
|
+
task,
|
|
1582
|
+
clean_evidence,
|
|
1583
|
+
clean_change_verify,
|
|
1584
|
+
outcome_notes,
|
|
1585
|
+
clean_change_summary,
|
|
1586
|
+
):
|
|
1587
|
+
real_world_evidence = "\n".join(
|
|
1588
|
+
part
|
|
1589
|
+
for part in (
|
|
1590
|
+
clean_evidence,
|
|
1591
|
+
clean_change_verify,
|
|
1592
|
+
outcome_notes,
|
|
1593
|
+
clean_change_summary,
|
|
1594
|
+
)
|
|
1595
|
+
if part
|
|
1596
|
+
)
|
|
1597
|
+
if _has_external_real_world_evidence(real_world_evidence):
|
|
1598
|
+
resolve_protocol_debts(
|
|
1599
|
+
task_id=task_id,
|
|
1600
|
+
debt_types=["external_real_world_verification_missing"],
|
|
1601
|
+
resolution="task_close evidence includes post-action real-world verification.",
|
|
1602
|
+
)
|
|
1603
|
+
else:
|
|
1604
|
+
debt = _ensure_open_debt(
|
|
1605
|
+
task["session_id"],
|
|
1606
|
+
task_id,
|
|
1607
|
+
"external_real_world_verification_missing",
|
|
1608
|
+
severity="error",
|
|
1609
|
+
evidence=(
|
|
1610
|
+
"External-stakes task closed as done without proof that the real sent/event/booking "
|
|
1611
|
+
f"artifact was reopened and verified. Goal: {task.get('goal','')}. "
|
|
1612
|
+
f"Evidence provided: {real_world_evidence[:240]!r}"
|
|
1613
|
+
),
|
|
1614
|
+
debts=debts_created,
|
|
1615
|
+
)
|
|
1616
|
+
return json.dumps(
|
|
1617
|
+
{
|
|
1618
|
+
"ok": False,
|
|
1619
|
+
"error": "Cannot close external-stakes task as 'done' without post-action real-world verification.",
|
|
1620
|
+
"hint": (
|
|
1621
|
+
"Re-open the sent email/message/calendar/booking artifact and verify recipients, "
|
|
1622
|
+
"CC/BCC, subject, body/signature, date/time/timezone, links, invitees, and attachments as applicable. "
|
|
1623
|
+
"Then retry nexo_task_close with that evidence."
|
|
1624
|
+
),
|
|
1625
|
+
"task_id": task_id,
|
|
1626
|
+
"blocked_by": "external_real_world_verify",
|
|
1627
|
+
"debt_id": debt.get("id"),
|
|
1628
|
+
"debt_type": "external_real_world_verification_missing",
|
|
1629
|
+
},
|
|
1630
|
+
ensure_ascii=False,
|
|
1631
|
+
indent=2,
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1465
1634
|
# ── Release checklist: require channel alignment evidence for release tasks ──
|
|
1466
1635
|
is_release = _is_release_task(
|
|
1467
1636
|
goal=task.get("goal") or "",
|
|
@@ -5,7 +5,7 @@ NEXO Followup Hygiene — Weekly cleanup of followup/reminder statuses.
|
|
|
5
5
|
|
|
6
6
|
Runs Sundays via LaunchAgent (or manually). Tasks:
|
|
7
7
|
1. Normalize dirty statuses (COMPLETED YYYY-MM-DD -> COMPLETED)
|
|
8
|
-
2.
|
|
8
|
+
2. Escalate PENDING followups >14 days without updates to needs_decision
|
|
9
9
|
3. Generate summary of orphaned/forgotten followups for synthesis
|
|
10
10
|
|
|
11
11
|
No CLI needed — this is pure mechanical cleanup.
|
|
@@ -85,21 +85,38 @@ def main():
|
|
|
85
85
|
)
|
|
86
86
|
log(f"Normalized {dirty_r} dirty reminder statuses")
|
|
87
87
|
|
|
88
|
-
# 2.
|
|
88
|
+
# 2. Escalate stale followups (PENDING >14 days, no updates)
|
|
89
89
|
cutoff = (date.today() - timedelta(days=14)).isoformat()
|
|
90
|
+
updated_cutoff = datetime.now().timestamp() - (14 * 24 * 60 * 60)
|
|
90
91
|
stale = conn.execute(
|
|
91
92
|
"SELECT id, description, date, updated_at FROM followups "
|
|
92
93
|
"WHERE status NOT LIKE 'COMPLETED%' "
|
|
93
|
-
"AND status NOT IN ('DELETED','archived','blocked','waiting') "
|
|
94
|
+
"AND status NOT IN ('DELETED','archived','blocked','waiting','needs_decision','waiting_user') "
|
|
94
95
|
"AND date != '' AND date < ? "
|
|
96
|
+
"AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
|
|
95
97
|
"ORDER BY date",
|
|
96
|
-
(cutoff,)
|
|
98
|
+
(cutoff, updated_cutoff)
|
|
97
99
|
).fetchall()
|
|
98
100
|
|
|
101
|
+
escalated_stale = []
|
|
99
102
|
if stale:
|
|
100
|
-
log(f"
|
|
103
|
+
log(f"Escalating {len(stale)} stale followups (>14 days overdue, no recent update):")
|
|
101
104
|
for s in stale[:10]:
|
|
102
105
|
log(f" {s['id']}: {s['description'][:60]} (due: {s['date']})")
|
|
106
|
+
for s in stale:
|
|
107
|
+
result = nexo_db.update_followup(
|
|
108
|
+
str(s["id"]),
|
|
109
|
+
status="needs_decision",
|
|
110
|
+
date=TODAY,
|
|
111
|
+
history_actor="followup-hygiene",
|
|
112
|
+
history_event="stale_triage",
|
|
113
|
+
history_note=(
|
|
114
|
+
"Weekly hygiene escalated this old due followup to needs_decision "
|
|
115
|
+
"instead of leaving it in the executable briefing indefinitely."
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
if not result.get("error"):
|
|
119
|
+
escalated_stale.append(str(s["id"]))
|
|
103
120
|
|
|
104
121
|
# 3. Orphaned followups (no date, no recent update)
|
|
105
122
|
orphans = conn.execute(
|
|
@@ -123,8 +140,10 @@ def main():
|
|
|
123
140
|
"date": TODAY,
|
|
124
141
|
"dirty_normalized": dirty_f + dirty_r,
|
|
125
142
|
"stale_count": len(stale) if stale else 0,
|
|
143
|
+
"stale_escalated_count": len(escalated_stale),
|
|
126
144
|
"orphan_count": len(orphans) if orphans else 0,
|
|
127
145
|
"stale_ids": [s["id"] for s in stale[:20]] if stale else [],
|
|
146
|
+
"stale_escalated_ids": escalated_stale[:20],
|
|
128
147
|
"orphan_ids": [o["id"] for o in orphans[:20]] if orphans else [],
|
|
129
148
|
}
|
|
130
149
|
|
|
@@ -132,7 +151,7 @@ def main():
|
|
|
132
151
|
summary_file.parent.mkdir(parents=True, exist_ok=True)
|
|
133
152
|
summary_file.write_text(json.dumps(summary, indent=2))
|
|
134
153
|
|
|
135
|
-
log(f"Summary: {dirty_f + dirty_r} normalized, {len(
|
|
154
|
+
log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(orphans) if orphans else 0} orphans")
|
|
136
155
|
log("=== Followup Hygiene complete ===")
|
|
137
156
|
|
|
138
157
|
|
|
@@ -74,6 +74,9 @@ CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
|
|
|
74
74
|
LOCK_FILE = LOG_DIR / "followup-runner.lock"
|
|
75
75
|
MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
|
|
76
76
|
COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
|
|
77
|
+
STALE_FOLLOWUP_TRIAGE_DAYS = 14
|
|
78
|
+
MAX_STALE_TRIAGE_PER_RUN = 8
|
|
79
|
+
MAX_NEEDS_OPERATOR_BRIEFING = 12
|
|
77
80
|
DEFAULT_ASSISTANT_NAME = "Nova"
|
|
78
81
|
DEFAULT_OPERATOR_LANGUAGE = "en"
|
|
79
82
|
|
|
@@ -101,6 +104,43 @@ def save_state(state: dict):
|
|
|
101
104
|
|
|
102
105
|
|
|
103
106
|
# ── DB access ───────────────────────────────────────────────────────────
|
|
107
|
+
def _parse_date(value: str) -> date | None:
|
|
108
|
+
try:
|
|
109
|
+
return date.fromisoformat(str(value or "").strip()[:10])
|
|
110
|
+
except ValueError:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _followup_days_overdue(date_value: str, *, today_value: date | None = None) -> int:
|
|
115
|
+
due = _parse_date(date_value)
|
|
116
|
+
if not due:
|
|
117
|
+
return 0
|
|
118
|
+
today_obj = today_value or date.today()
|
|
119
|
+
return max(0, (today_obj - due).days)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _history_has_recent_movement(history, *, days: int = STALE_FOLLOWUP_TRIAGE_DAYS) -> bool:
|
|
123
|
+
if not history:
|
|
124
|
+
return False
|
|
125
|
+
cutoff = date.today() - timedelta(days=days)
|
|
126
|
+
for event in history:
|
|
127
|
+
if not isinstance(event, dict):
|
|
128
|
+
continue
|
|
129
|
+
created = _parse_date(str(event.get("created_at") or event.get("date") or ""))
|
|
130
|
+
if created and created >= cutoff:
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_stale_followup_for_triage(followup: dict) -> bool:
|
|
136
|
+
status = str(followup.get("status") or "").strip().lower()
|
|
137
|
+
if status in {"needs_decision", "waiting_user", "blocked", "waiting"}:
|
|
138
|
+
return False
|
|
139
|
+
if _followup_days_overdue(str(followup.get("date") or "")) < STALE_FOLLOWUP_TRIAGE_DAYS:
|
|
140
|
+
return False
|
|
141
|
+
return not _history_has_recent_movement(followup.get("history") or [])
|
|
142
|
+
|
|
143
|
+
|
|
104
144
|
def _is_in_cooldown(fu_id: str, state: dict) -> bool:
|
|
105
145
|
"""Check if a followup was recently attempted and should be skipped."""
|
|
106
146
|
attempts = state.get("attempts", {})
|
|
@@ -252,14 +292,14 @@ def get_all_active_followups(state: dict) -> dict:
|
|
|
252
292
|
operator_name = str(operator.get("operator_name") or "the operator")
|
|
253
293
|
if not NEXO_DB.exists():
|
|
254
294
|
log(f"DB not found: {NEXO_DB}")
|
|
255
|
-
return {"actionable": [], "needs_operator": [], "future": [], "backlog": []}
|
|
295
|
+
return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
|
|
256
296
|
|
|
257
297
|
today = date.today().isoformat()
|
|
258
298
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
259
299
|
conn.row_factory = sqlite3.Row
|
|
260
300
|
try:
|
|
261
301
|
rows = conn.execute(
|
|
262
|
-
"SELECT id, description, date, reasoning, verification, priority, recurrence, status "
|
|
302
|
+
"SELECT id, description, date, reasoning, verification, priority, recurrence, status, owner, updated_at "
|
|
263
303
|
"FROM followups WHERE status NOT LIKE 'COMPLETED%' "
|
|
264
304
|
"AND UPPER(COALESCE(status, '')) NOT IN ('BLOCKED', 'ARCHIVED', 'DELETED', 'WAITING') "
|
|
265
305
|
"AND description NOT LIKE '[Abandoned]%' "
|
|
@@ -270,7 +310,7 @@ def get_all_active_followups(state: dict) -> dict:
|
|
|
270
310
|
" date ASC"
|
|
271
311
|
).fetchall()
|
|
272
312
|
|
|
273
|
-
result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": []}
|
|
313
|
+
result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
|
|
274
314
|
undated_triage_budget = 2
|
|
275
315
|
|
|
276
316
|
for row in rows:
|
|
@@ -296,7 +336,12 @@ def get_all_active_followups(state: dict) -> dict:
|
|
|
296
336
|
result["actionable"].append(triage_fu)
|
|
297
337
|
undated_triage_budget -= 1
|
|
298
338
|
elif fu_date <= today:
|
|
299
|
-
if
|
|
339
|
+
if _is_stale_followup_for_triage(fu):
|
|
340
|
+
stale_fu = dict(fu)
|
|
341
|
+
stale_fu["stale_triage"] = True
|
|
342
|
+
stale_fu["days_overdue"] = _followup_days_overdue(fu_date)
|
|
343
|
+
result["stale_triage"].append(stale_fu)
|
|
344
|
+
elif needs_operator:
|
|
300
345
|
result["needs_operator"].append(fu)
|
|
301
346
|
elif _is_in_cooldown(fu["id"], state):
|
|
302
347
|
result["cooled_down"].append(fu)
|
|
@@ -310,12 +355,16 @@ def get_all_active_followups(state: dict) -> dict:
|
|
|
310
355
|
overflow = result["actionable"][MAX_FOLLOWUPS_PER_RUN:]
|
|
311
356
|
result["actionable"] = result["actionable"][:MAX_FOLLOWUPS_PER_RUN]
|
|
312
357
|
log(f"Capped actionable to {MAX_FOLLOWUPS_PER_RUN}, deferred {len(overflow)} to next run")
|
|
358
|
+
if len(result["needs_operator"]) > MAX_NEEDS_OPERATOR_BRIEFING:
|
|
359
|
+
overflow = result["needs_operator"][MAX_NEEDS_OPERATOR_BRIEFING:]
|
|
360
|
+
result["needs_operator"] = result["needs_operator"][:MAX_NEEDS_OPERATOR_BRIEFING]
|
|
361
|
+
log(f"Capped needs_operator to {MAX_NEEDS_OPERATOR_BRIEFING}, deferred {len(overflow)} noisy items")
|
|
313
362
|
|
|
314
363
|
return result
|
|
315
364
|
|
|
316
365
|
except Exception as e:
|
|
317
366
|
log(f"DB error: {e}")
|
|
318
|
-
return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": []}
|
|
367
|
+
return {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
|
|
319
368
|
finally:
|
|
320
369
|
conn.close()
|
|
321
370
|
|
|
@@ -753,10 +802,37 @@ def main():
|
|
|
753
802
|
groups = get_all_active_followups(state)
|
|
754
803
|
all_actionable = list(groups["actionable"])
|
|
755
804
|
cooled = groups.get("cooled_down", [])
|
|
805
|
+
stale_triage = groups.get("stale_triage", [])
|
|
756
806
|
|
|
757
807
|
log(f"Actionable: {len(all_actionable)}, Cooled down: {len(cooled)}, "
|
|
758
808
|
f"Needs operator: {len(groups['needs_operator'])}, "
|
|
759
|
-
f"Future: {len(groups['future'])}, Backlog: {len(groups['backlog'])}"
|
|
809
|
+
f"Future: {len(groups['future'])}, Backlog: {len(groups['backlog'])}, "
|
|
810
|
+
f"Stale triage: {len(stale_triage)}")
|
|
811
|
+
|
|
812
|
+
for fu in stale_triage[:MAX_STALE_TRIAGE_PER_RUN]:
|
|
813
|
+
fid = str(fu.get("id") or "")
|
|
814
|
+
if not fid:
|
|
815
|
+
continue
|
|
816
|
+
days_overdue = int(fu.get("days_overdue") or 0)
|
|
817
|
+
summary = (
|
|
818
|
+
f"Followup overdue for {days_overdue} days without recent movement. "
|
|
819
|
+
"Operator decision required: close as obsolete, reschedule with reason, or convert into a concrete next action."
|
|
820
|
+
)
|
|
821
|
+
update_followup_fields(
|
|
822
|
+
fid,
|
|
823
|
+
date_value=date.today().isoformat(),
|
|
824
|
+
status="needs_decision",
|
|
825
|
+
history_event="stale_triage",
|
|
826
|
+
history_note=summary,
|
|
827
|
+
)
|
|
828
|
+
upsert_attention_reminder(
|
|
829
|
+
fid,
|
|
830
|
+
summary=summary,
|
|
831
|
+
options={"a": "close obsolete", "b": "reschedule", "c": "convert to next action"},
|
|
832
|
+
status="needs_decision",
|
|
833
|
+
operator_language=_operator_language(),
|
|
834
|
+
)
|
|
835
|
+
record_attempt(state, fid, "needs_decision")
|
|
760
836
|
|
|
761
837
|
results = []
|
|
762
838
|
|
|
@@ -851,7 +927,7 @@ def main():
|
|
|
851
927
|
record_attempt(state, fid, r["status"])
|
|
852
928
|
log(f" {fid}: {r['status']} -> cooldown {COOLDOWN_DAYS} days")
|
|
853
929
|
|
|
854
|
-
total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"])
|
|
930
|
+
total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"]) + len(stale_triage)
|
|
855
931
|
attention_handed_off = any(
|
|
856
932
|
r.get("needs_attention") or r["status"] in ("needs_decision", "blocked")
|
|
857
933
|
for r in results
|
package/src/tools_sessions.py
CHANGED
|
@@ -478,6 +478,18 @@ def handle_startup(
|
|
|
478
478
|
lines.append(f" {raw_line}")
|
|
479
479
|
lines.append(f" Full briefing: {briefing_path}")
|
|
480
480
|
|
|
481
|
+
try:
|
|
482
|
+
from memory_layer_audit import audit_memory_layers, format_memory_layer_warnings
|
|
483
|
+
|
|
484
|
+
memory_warnings = format_memory_layer_warnings(audit_memory_layers(max_warnings=4))
|
|
485
|
+
if memory_warnings:
|
|
486
|
+
lines.append("")
|
|
487
|
+
lines.append("MEMORY LAYER CHECK:")
|
|
488
|
+
for raw_line in memory_warnings:
|
|
489
|
+
lines.append(f" {raw_line}")
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
481
493
|
# Check LaunchAgent health (macOS only)
|
|
482
494
|
la_warnings = _check_launchagents()
|
|
483
495
|
if la_warnings:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-claude-md-version: 2.1.
|
|
1
|
+
<!-- nexo-claude-md-version: 2.1.8 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — Cognitive Co-Operator
|
|
@@ -87,6 +87,7 @@ Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name
|
|
|
87
87
|
## User Profile
|
|
88
88
|
- **Calibration:** `{{NEXO_HOME}}/brain/calibration.json` (personality settings + language + user name)
|
|
89
89
|
- **Profile:** `{{NEXO_HOME}}/brain/profile.json` (deep scan results from onboarding)
|
|
90
|
+
- **Memory authority:** `calibration.json` + `profile.json` + NEXO Brain DB win over legacy client memory. Do not read, write, or rely on `MEMORY.md` / `.claude/memories` / `.codex/memories` as source of truth; surface conflicts at startup instead of carrying old facts silently.
|
|
90
91
|
|
|
91
92
|
### First Session Onboarding (only if profile.json lacks `role` or `technical_level`)
|
|
92
93
|
Ask TWO questions: (1) "What do you do?" -> save to profile.json + `nexo_preference_set("role", answer)`. (2) "Technical level? Beginner / Intermediate / Advanced" -> save + `nexo_preference_set("technical_level", answer)`. Then: "Got it. From now on I learn by observing." Never ask onboarding questions again.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-codex-agents-version: 1.2.
|
|
1
|
+
<!-- nexo-codex-agents-version: 1.2.7 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — NEXO Shared Brain for Codex
|
|
@@ -98,6 +98,11 @@ Operational rule R34 (Layer 2) watches this coherence.
|
|
|
98
98
|
- **Skills:** reusable procedures plus `nexo_skill_evolution_candidates` for text->script and skill refinement.
|
|
99
99
|
- **Watchdog / Immune / Followups:** reliability, quarantine, reminders, and self-healing are native parts of NEXO.
|
|
100
100
|
|
|
101
|
+
## Memory Authority
|
|
102
|
+
- `calibration.json`, `profile.json`, and NEXO Brain DB are authoritative for identity, profile, decisions, learnings, diary, and followups.
|
|
103
|
+
- Legacy client memory files such as `MEMORY.md`, `.claude/memories`, and `.codex/memories` are lowest-authority read-only leftovers. Do not write them or use them to override Brain/calibration/profile.
|
|
104
|
+
- If bootstrap text and Brain profile disagree, surface the conflict at startup and use Brain/calibration/profile until corrected.
|
|
105
|
+
|
|
101
106
|
## Project Atlas
|
|
102
107
|
Search `{{NEXO_HOME}}/brain/project-atlas.json` BEFORE touching any project. Never assume server, port, or code location.
|
|
103
108
|
|