nexo-brain 7.12.15 → 7.13.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.15",
3
+ "version": "7.13.3",
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.12.15` is the current packaged-runtime line. Patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
21
+ Version `7.13.3` is the current packaged-runtime line. Unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI parity is release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
22
+
23
+ Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
22
24
 
23
25
  Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.15",
3
+ "version": "7.13.3",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
152
152
  delete_diary_draft(sid)
153
153
 
154
154
 
155
+ def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
156
+ """Close stale open protocol tasks as partial when their session is reaped."""
157
+ rows = conn.execute(
158
+ """SELECT task_id, goal
159
+ FROM protocol_tasks
160
+ WHERE session_id = ? AND status = 'open'
161
+ ORDER BY opened_at ASC""",
162
+ (sid,),
163
+ ).fetchall()
164
+ closed: list[str] = []
165
+ for row in rows:
166
+ task_id = row["task_id"]
167
+ goal = str(row["goal"] or "")
168
+ evidence = (
169
+ f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
170
+ f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
171
+ )
172
+ conn.execute(
173
+ """UPDATE protocol_tasks
174
+ SET status = 'partial',
175
+ close_evidence = ?,
176
+ outcome_notes = 'auto-close: stale session ended without explicit task_close',
177
+ closed_at = datetime('now')
178
+ WHERE task_id = ? AND status = 'open'""",
179
+ (evidence[:4000], task_id),
180
+ )
181
+ closed.append(task_id)
182
+ return closed
183
+
184
+
155
185
  def main():
156
186
  init_db()
157
187
  conn = get_db()
@@ -161,9 +191,12 @@ def main():
161
191
  print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
162
192
  return
163
193
 
194
+ closed_task_ids: list[str] = []
164
195
  for session in orphans:
165
196
  sid = session["sid"]
166
197
  draft = get_diary_draft(sid)
198
+ closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
199
+ closed_task_ids.extend(closed_tasks)
167
200
 
168
201
  if draft:
169
202
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
@@ -196,7 +229,10 @@ def main():
196
229
  os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
197
230
  with open(AUTO_CLOSE_LOG, "a") as f:
198
231
  ts = datetime.datetime.now().isoformat(timespec="seconds")
199
- f.write(f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]}\n")
232
+ f.write(
233
+ f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
234
+ f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
235
+ )
200
236
 
201
237
 
202
238
  if __name__ == "__main__":
@@ -704,10 +704,6 @@ def _client_assumption_regressions() -> list[str]:
704
704
  }
705
705
  offenders: list[str] = []
706
706
  for path in src_root.rglob("*.py"):
707
- try:
708
- text = path.read_text()
709
- except Exception:
710
- continue
711
707
  resolved = path.resolve()
712
708
  try:
713
709
  if resolved.is_relative_to(backup_root):
@@ -723,6 +719,12 @@ def _client_assumption_regressions() -> list[str]:
723
719
  relative_path = resolved.relative_to(src_root)
724
720
  except Exception:
725
721
  relative_path = path.relative_to(src_root)
722
+ if "versions" in relative_path.parts:
723
+ continue
724
+ try:
725
+ text = path.read_text()
726
+ except Exception:
727
+ continue
726
728
  if ".claude/projects" in text and relative_path not in allowed_relative_paths:
727
729
  offenders.append(f"{relative_path} hardcodes ~/.claude/projects")
728
730
  collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  import paths
14
14
 
15
15
  from core_prompts import render_core_prompt
16
- from db import create_protocol_debt, get_db, get_last_heartbeat_ts
16
+ from db import create_protocol_debt, create_protocol_task, get_db, get_last_heartbeat_ts
17
17
  from operator_language import append_operator_language_contract
18
18
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
19
19
  from protocol_settings import get_protocol_strictness
@@ -429,6 +429,42 @@ def _strict_write_without_task_severity(session_id: str) -> str:
429
429
  return "error"
430
430
 
431
431
 
432
+ def _auto_task_open_enabled() -> bool:
433
+ return os.environ.get("NEXO_AUTO_TASK_OPEN", "1").strip().lower() not in {"0", "false", "no", "off"}
434
+
435
+
436
+ def _auto_open_protocol_task_for_write(*, sid: str, tool_name: str, operation: str, files: list[str]) -> dict | None:
437
+ if not sid or not _auto_task_open_enabled():
438
+ return None
439
+ task_type = "edit" if operation == "write" else "execute"
440
+ clean_files = [item for item in files if str(item or "").strip()]
441
+ target = ", ".join(clean_files[:3]) if clean_files else "unknown target"
442
+ if len(clean_files) > 3:
443
+ target += f", +{len(clean_files) - 3} more"
444
+ try:
445
+ return create_protocol_task(
446
+ sid,
447
+ f"Auto-opened {task_type} task for {tool_name} on {target}",
448
+ task_type=task_type,
449
+ area="auto",
450
+ context_hint="PreToolUse auto-task_open: write/delete attempted without a matching open task.",
451
+ files=clean_files,
452
+ plan=[
453
+ "Auto-opened because the agent attempted a write/delete before explicit task_open.",
454
+ "Verify the edit and close with evidence.",
455
+ ],
456
+ constraints=[
457
+ "Do not treat this auto-open as success evidence.",
458
+ "Close as done only after verification; otherwise close partial/failed.",
459
+ ],
460
+ verification_step="Run the relevant test or inspection and close with evidence.",
461
+ must_verify=True,
462
+ must_change_log=True,
463
+ )
464
+ except Exception:
465
+ return None
466
+
467
+
432
468
  def _resolve_runtime_path(path: str) -> Path:
433
469
  candidate = Path(str(path or "")).expanduser()
434
470
  if not candidate.is_absolute():
@@ -1599,6 +1635,24 @@ def process_pre_tool_event(payload: dict) -> dict:
1599
1635
  "status": "blocked",
1600
1636
  }
1601
1637
 
1638
+ auto_opened_task = None
1639
+ if files:
1640
+ missing_task_files = [filepath for filepath in files if not _find_open_task_for_file(conn, sid, filepath)]
1641
+ if missing_task_files:
1642
+ auto_opened_task = _auto_open_protocol_task_for_write(
1643
+ sid=sid,
1644
+ tool_name=tool_name,
1645
+ operation=op,
1646
+ files=missing_task_files,
1647
+ )
1648
+ elif not _find_any_open_task(conn, sid):
1649
+ auto_opened_task = _auto_open_protocol_task_for_write(
1650
+ sid=sid,
1651
+ tool_name=tool_name,
1652
+ operation=op,
1653
+ files=[],
1654
+ )
1655
+
1602
1656
  if not files:
1603
1657
  task = _find_any_open_task(conn, sid)
1604
1658
  if not task:
@@ -1628,6 +1682,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1628
1682
  "operation": op,
1629
1683
  "strictness": strictness,
1630
1684
  "blocks": blocks,
1685
+ "auto_opened_task": auto_opened_task,
1631
1686
  "status": "blocked" if blocks else "clean",
1632
1687
  }
1633
1688
 
@@ -1711,6 +1766,7 @@ def process_pre_tool_event(payload: dict) -> dict:
1711
1766
  "operation": op,
1712
1767
  "strictness": strictness,
1713
1768
  "blocks": blocks,
1769
+ "auto_opened_task": auto_opened_task,
1714
1770
  "status": "blocked" if blocks else "clean",
1715
1771
  }
1716
1772
 
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Best-effort PostToolUse change_log recorder for write tools.
5
+
6
+ This hook records file edit visibility directly in the DB layer. It never
7
+ calls MCP tools and never blocks the client pipeline.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ _DIR = Path(__file__).resolve().parent
17
+ _SRC = _DIR.parent
18
+ if str(_SRC) not in sys.path:
19
+ sys.path.insert(0, str(_SRC))
20
+
21
+ WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
22
+
23
+
24
+ def _short_tool_name(tool_name: str) -> str:
25
+ clean = str(tool_name or "").strip()
26
+ return clean.rsplit("__", 1)[-1] if "__" in clean else clean
27
+
28
+
29
+ def _read_stdin_json() -> dict:
30
+ if sys.stdin.isatty():
31
+ return {}
32
+ try:
33
+ raw = sys.stdin.read()
34
+ if not raw.strip():
35
+ return {}
36
+ payload = json.loads(raw)
37
+ return payload if isinstance(payload, dict) else {}
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ def _tool_input(payload: dict) -> dict:
43
+ value = payload.get("tool_input") or payload.get("toolInput") or payload.get("input") or {}
44
+ return value if isinstance(value, dict) else {}
45
+
46
+
47
+ def _extract_file_paths(payload: dict) -> list[str]:
48
+ tool_input = _tool_input(payload)
49
+ candidates: list[str] = []
50
+ for key in ("file_path", "filePath", "path", "notebook_path", "notebookPath"):
51
+ value = tool_input.get(key)
52
+ if isinstance(value, str) and value.strip():
53
+ candidates.append(value.strip())
54
+ for key in ("file_paths", "filePaths", "paths"):
55
+ value = tool_input.get(key)
56
+ if isinstance(value, list):
57
+ candidates.extend(str(item).strip() for item in value if str(item).strip())
58
+ seen: set[str] = set()
59
+ paths: list[str] = []
60
+ for item in candidates:
61
+ if item in seen:
62
+ continue
63
+ seen.add(item)
64
+ paths.append(item)
65
+ return paths
66
+
67
+
68
+ def _resolve_sid_from_payload(payload: dict) -> str:
69
+ candidates: list[str] = []
70
+ for key in ("nexo_sid", "sid", "session_id", "sessionId"):
71
+ value = payload.get(key)
72
+ if isinstance(value, str) and value.strip():
73
+ candidates.append(value.strip())
74
+ for key in ("NEXO_SID", "CLAUDE_SESSION_ID"):
75
+ value = os.environ.get(key, "").strip()
76
+ if value:
77
+ candidates.append(value)
78
+ try:
79
+ from db import resolve_sid_from_external
80
+ except Exception:
81
+ return ""
82
+ for candidate in candidates:
83
+ if candidate.startswith("nexo-"):
84
+ return candidate
85
+ resolved = resolve_sid_from_external(candidate)
86
+ if resolved:
87
+ return resolved
88
+ return ""
89
+
90
+
91
+ def record_post_edit_change(payload: dict) -> dict:
92
+ """Record a minimal change_log row for write-like tool payloads."""
93
+ tool_name = _short_tool_name(str(payload.get("tool_name") or payload.get("toolName") or ""))
94
+ if tool_name not in WRITE_TOOLS:
95
+ return {"ok": True, "skipped": True, "reason": "tool_not_write"}
96
+ if os.environ.get("NEXO_AUTO_CHANGE_LOG", "1").strip().lower() in {"0", "false", "no", "off"}:
97
+ return {"ok": True, "skipped": True, "reason": "disabled"}
98
+
99
+ paths = _extract_file_paths(payload)
100
+ if not paths:
101
+ return {"ok": True, "skipped": True, "reason": "missing_file_path"}
102
+
103
+ try:
104
+ from db import init_db, log_change
105
+ except Exception as exc:
106
+ return {"ok": False, "error": f"db_import_failed: {exc}"}
107
+
108
+ try:
109
+ init_db()
110
+ sid = _resolve_sid_from_payload(payload) or "unknown"
111
+ files = ", ".join(paths)
112
+ result = log_change(
113
+ sid,
114
+ files,
115
+ f"Auto-recorded PostToolUse {tool_name} file edit",
116
+ "PostToolUse observed a file write; recording traceability even if the agent forgets nexo_change_log.",
117
+ triggered_by="post_edit_change_log.py",
118
+ affects=files,
119
+ risks="Automatic trace only; verify the actual diff and tests separately.",
120
+ verify="Inspect git diff and run the relevant tests for the edited file.",
121
+ commit_ref="",
122
+ )
123
+ if "error" in result:
124
+ return {"ok": False, "error": result["error"]}
125
+ return {"ok": True, "change_log_id": result.get("id"), "files": paths}
126
+ except Exception as exc:
127
+ return {"ok": False, "error": str(exc)}
128
+
129
+
130
+ def main() -> int:
131
+ record_post_edit_change(_read_stdin_json())
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())
@@ -208,6 +208,21 @@ def _run_auto_capture(payload: dict) -> int:
208
208
  return 1
209
209
 
210
210
 
211
+ def _run_post_edit_change_log(payload: dict) -> int:
212
+ """Record write-tool visibility without calling MCP from the hook."""
213
+ try:
214
+ proc = subprocess.run(
215
+ ["python3", str(_DIR / "post_edit_change_log.py")],
216
+ input=json.dumps(payload),
217
+ capture_output=True,
218
+ text=True,
219
+ timeout=5,
220
+ )
221
+ return proc.returncode
222
+ except Exception:
223
+ return 1
224
+
225
+
211
226
  def main() -> int:
212
227
  started = time.time()
213
228
  payload = _read_stdin_json()
@@ -231,6 +246,7 @@ def main() -> int:
231
246
  protocol_message = stdout
232
247
 
233
248
  exits.append(_run_auto_capture(payload))
249
+ exits.append(_run_post_edit_change_log(payload))
234
250
 
235
251
  # v6.0.1 — inbox autodetect runs LAST so it sees the latest DB state
236
252
  # (including any writes the previous steps may have done). Emits a
@@ -16,6 +16,7 @@ from runtime_versioning import (
16
16
  activate_versioned_runtime_snapshot,
17
17
  compute_mcp_runtime_fingerprint,
18
18
  installed_force_restart_flag,
19
+ prune_old_versioned_runtime_snapshots,
19
20
  write_restart_required_marker,
20
21
  )
21
22
 
@@ -1236,6 +1237,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1236
1237
 
1237
1238
  versioned_runtime_summary = None
1238
1239
  restart_marker_summary = None
1240
+ version_prune_summary = None
1239
1241
  mcp_code_changed = False
1240
1242
  force_restart = False
1241
1243
  new_fingerprint = ""
@@ -1246,6 +1248,10 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1246
1248
  source_root=_runtime_code_root(),
1247
1249
  version=new_version,
1248
1250
  )
1251
+ version_prune_summary = prune_old_versioned_runtime_snapshots(
1252
+ keep=2,
1253
+ active_version=new_version,
1254
+ )
1249
1255
  except Exception as e:
1250
1256
  errors.append(f"versioned runtime activation: {e}")
1251
1257
  # Decide whether the new release actually altered any MCP-loaded
@@ -1346,6 +1352,10 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1346
1352
  lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
1347
1353
  if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
1348
1354
  lines.append(f" Runtime activation: core/current -> versions/{new_version}")
1355
+ if version_prune_summary and version_prune_summary.get("pruned"):
1356
+ lines.append(
1357
+ f" Cleanup: pruned {len(version_prune_summary['pruned'])} old runtime version snapshot(s)"
1358
+ )
1349
1359
  if restart_marker_summary:
1350
1360
  lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
1351
1361
  lines.append("")
@@ -1579,6 +1589,7 @@ def handle_update(
1579
1589
 
1580
1590
  versioned_runtime_summary = None
1581
1591
  restart_marker_summary = None
1592
+ version_prune_summary = None
1582
1593
  mcp_code_changed = False
1583
1594
  force_restart = False
1584
1595
  new_fingerprint = ""
@@ -1589,6 +1600,10 @@ def handle_update(
1589
1600
  source_root=SRC_DIR,
1590
1601
  version=new_version,
1591
1602
  )
1603
+ version_prune_summary = prune_old_versioned_runtime_snapshots(
1604
+ keep=2,
1605
+ active_version=new_version,
1606
+ )
1592
1607
  steps_done.append("versioned-runtime")
1593
1608
  except Exception as e:
1594
1609
  raise RuntimeError(f"Versioned runtime activation failed: {e}")
@@ -1662,6 +1677,10 @@ def handle_update(
1662
1677
  lines.append(" Clients: configured client targets synced")
1663
1678
  if versioned_runtime_summary and versioned_runtime_summary.get("ok"):
1664
1679
  lines.append(f" Runtime activation: core/current -> versions/{new_version}")
1680
+ if version_prune_summary and version_prune_summary.get("pruned"):
1681
+ lines.append(
1682
+ f" Cleanup: pruned {len(version_prune_summary['pruned'])} old runtime version snapshot(s)"
1683
+ )
1665
1684
  if restart_marker_summary:
1666
1685
  lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
1667
1686
  lines.append("")
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import hashlib
4
5
  import json
5
6
  import os
7
+ import re
6
8
  import shutil
7
9
  import time
8
10
  from dataclasses import dataclass
@@ -539,6 +541,67 @@ def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, vers
539
541
  }
540
542
 
541
543
 
544
+ def _runtime_version_sort_key(path: Path) -> tuple:
545
+ parts = re.split(r"([0-9]+)", path.name)
546
+ key = []
547
+ for part in parts:
548
+ if not part:
549
+ continue
550
+ if part.isdigit():
551
+ key.append((0, int(part)))
552
+ else:
553
+ key.append((1, part.lower()))
554
+ return tuple(key)
555
+
556
+
557
+ def _active_version_name() -> str:
558
+ current = core_current_link()
559
+ if current.exists() or current.is_symlink():
560
+ with contextlib.suppress(Exception):
561
+ resolved = current.resolve(strict=False)
562
+ if resolved.parent.name == "versions":
563
+ return resolved.name
564
+ return installed_runtime_version()
565
+
566
+
567
+ def prune_old_versioned_runtime_snapshots(*, keep: int = 2, active_version: str = "") -> dict:
568
+ """Remove runtime snapshots older than the active + newest ``keep`` versions."""
569
+ keep = max(int(keep or 0), 1)
570
+ versions_dir = core_versions_dir()
571
+ report = {
572
+ "ok": True,
573
+ "versions_dir": str(versions_dir),
574
+ "keep": keep,
575
+ "active_version": str(active_version or "").strip(),
576
+ "kept": [],
577
+ "pruned": [],
578
+ "errors": [],
579
+ }
580
+ if not versions_dir.is_dir():
581
+ return report
582
+
583
+ snapshots = [item for item in versions_dir.iterdir() if item.is_dir() and not item.is_symlink()]
584
+ snapshots.sort(key=_runtime_version_sort_key)
585
+ active = str(active_version or _active_version_name() or "").strip()
586
+ report["active_version"] = active
587
+
588
+ keep_names = {item.name for item in snapshots[-keep:]}
589
+ if active:
590
+ keep_names.add(active)
591
+
592
+ for snapshot in snapshots:
593
+ if snapshot.name in keep_names:
594
+ report["kept"].append(snapshot.name)
595
+ continue
596
+ try:
597
+ shutil.rmtree(snapshot)
598
+ report["pruned"].append(snapshot.name)
599
+ except Exception as exc:
600
+ report["ok"] = False
601
+ report["errors"].append({"version": snapshot.name, "error": str(exc)})
602
+ return report
603
+
604
+
542
605
  def clear_restart_required_marker(
543
606
  *,
544
607
  client: str = "",
@@ -95,6 +95,24 @@ SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
95
95
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
96
96
  SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
97
97
  PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
98
+ _RUNTIME_METADATA_ALIASES = {
99
+ "bash": "shell",
100
+ "sh": "shell",
101
+ "zsh": "shell",
102
+ "shellscript": "shell",
103
+ "python3": "python",
104
+ "py": "python",
105
+ "nodejs": "node",
106
+ "javascript": "node",
107
+ }
108
+ _SCHEDULE_WEEKDAY_SUFFIX_RE = re.compile(
109
+ r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})\s+weekday\s*=\s*(?P<weekday>\d)$",
110
+ re.IGNORECASE,
111
+ )
112
+ _SCHEDULE_WEEKDAY_PREFIX_RE = re.compile(
113
+ r"^weekday\s*=\s*(?P<weekday>\d)\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})$",
114
+ re.IGNORECASE,
115
+ )
98
116
  _LEGACY_CORE_SCRIPT_ALIASES = {
99
117
  "nexo-postcompact.sh": "post-compact.sh",
100
118
  "nexo-memory-precompact.sh": "pre-compact.sh",
@@ -341,10 +359,40 @@ def parse_inline_metadata(path: Path) -> dict:
341
359
  key, value = payload.split("=", 1)
342
360
  k = key.strip()
343
361
  if k in METADATA_KEYS:
344
- meta[k] = value.strip()
362
+ meta[k] = _normalize_metadata_value(k, value.strip())
345
363
  return meta
346
364
 
347
365
 
366
+ def _normalize_metadata_value(key: str, value: str) -> str:
367
+ if key == "runtime":
368
+ return _normalize_runtime_metadata(value)
369
+ if key == "schedule":
370
+ return _normalize_schedule_metadata(value)
371
+ return value
372
+
373
+
374
+ def _normalize_runtime_metadata(value: str) -> str:
375
+ candidate = str(value or "").strip().lower()
376
+ return _RUNTIME_METADATA_ALIASES.get(candidate, candidate)
377
+
378
+
379
+ def _normalize_schedule_metadata(value: str) -> str:
380
+ candidate = re.sub(r"\s+", " ", str(value or "").strip())
381
+ for pattern in (_SCHEDULE_WEEKDAY_SUFFIX_RE, _SCHEDULE_WEEKDAY_PREFIX_RE):
382
+ match = pattern.match(candidate)
383
+ if not match:
384
+ continue
385
+ try:
386
+ hour = int(match.group("hour"))
387
+ minute = int(match.group("minute"))
388
+ weekday = int(match.group("weekday"))
389
+ except ValueError:
390
+ return candidate
391
+ if 0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= weekday <= 6:
392
+ return f"{hour:02d}:{minute:02d}:{weekday}"
393
+ return candidate
394
+
395
+
348
396
  def _detect_shebang(path: Path) -> str | None:
349
397
  """Read first line for shebang."""
350
398
  try:
@@ -1068,6 +1116,169 @@ def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | l
1068
1116
  return str(schedule_value or "")
1069
1117
 
1070
1118
 
1119
+ def _metadata_comment_prefix(path: Path) -> str:
1120
+ return "//" if path.suffix.lower() == ".js" else "#"
1121
+
1122
+
1123
+ def _compact_schedule_from_record(record: dict) -> str:
1124
+ raw = str(record.get("schedule_value") or "").strip()
1125
+ if not raw:
1126
+ return ""
1127
+ payload = None
1128
+ if raw.lstrip().startswith("{") or raw.lstrip().startswith("["):
1129
+ with contextlib.suppress(Exception):
1130
+ payload = json.loads(raw)
1131
+ if isinstance(payload, list):
1132
+ payload = payload[0] if len(payload) == 1 and isinstance(payload[0], dict) else None
1133
+ if isinstance(payload, dict):
1134
+ hour = payload.get("Hour")
1135
+ minute = payload.get("Minute")
1136
+ weekday = payload.get("Weekday")
1137
+ try:
1138
+ hour_i = int(hour)
1139
+ minute_i = int(minute)
1140
+ weekday_i = int(weekday) if weekday is not None else None
1141
+ except (TypeError, ValueError):
1142
+ return ""
1143
+ if not (0 <= hour_i <= 23 and 0 <= minute_i <= 59):
1144
+ return ""
1145
+ if weekday_i is not None:
1146
+ if not (0 <= weekday_i <= 6):
1147
+ return ""
1148
+ return f"{hour_i:02d}:{minute_i:02d}:{weekday_i}"
1149
+ return f"{hour_i:02d}:{minute_i:02d}"
1150
+
1151
+ normalized = _normalize_schedule_metadata(raw)
1152
+ if _calendar_payload_from_declared(normalized) is not None:
1153
+ return normalized
1154
+
1155
+ label = str(record.get("schedule_label") or "").strip()
1156
+ match = re.search(r"(?P<hour>\d{1,2}):(?P<minute>\d{2})(?:\s+weekday=(?P<weekday>\d))?", label)
1157
+ if not match:
1158
+ return ""
1159
+ weekday_part = f":{match.group('weekday')}" if match.group("weekday") is not None else ""
1160
+ return f"{int(match.group('hour')):02d}:{int(match.group('minute')):02d}{weekday_part}"
1161
+
1162
+
1163
+ def _inferred_schedule_metadata_lines(path: Path, metadata: dict, record: dict) -> list[str] | None:
1164
+ schedule_type = str(record.get("schedule_type") or "")
1165
+ if schedule_type not in {"interval", "calendar", "keep_alive"}:
1166
+ return None
1167
+
1168
+ name = _logical_personal_script_name(str(metadata.get("name") or path.stem))
1169
+ description = str(metadata.get("description") or "Personal automation managed by NEXO").strip()
1170
+ runtime = classify_runtime(path, metadata)
1171
+ if runtime == "unknown":
1172
+ runtime = "shell" if path.suffix.lower() == ".sh" else "python"
1173
+ cron_id = _safe_slug(str(record.get("cron_id") or metadata.get("cron_id") or name))
1174
+ prefix = _metadata_comment_prefix(path)
1175
+
1176
+ lines = [
1177
+ f"{prefix} nexo: name={name}",
1178
+ f"{prefix} nexo: description={description}",
1179
+ f"{prefix} nexo: runtime={runtime}",
1180
+ f"{prefix} nexo: cron_id={cron_id}",
1181
+ f"{prefix} nexo: schedule_required=true",
1182
+ ]
1183
+ if schedule_type == "interval":
1184
+ try:
1185
+ interval = int(str(record.get("schedule_value") or "").strip())
1186
+ except ValueError:
1187
+ return None
1188
+ if interval <= 0:
1189
+ return None
1190
+ lines.append(f"{prefix} nexo: interval_seconds={interval}")
1191
+ lines.append(f"{prefix} nexo: recovery_policy=run_once_on_wake")
1192
+ elif schedule_type == "calendar":
1193
+ compact_schedule = _compact_schedule_from_record(record)
1194
+ if not compact_schedule:
1195
+ return None
1196
+ lines.append(f"{prefix} nexo: schedule={compact_schedule}")
1197
+ lines.append(f"{prefix} nexo: recovery_policy=catchup")
1198
+ elif schedule_type == "keep_alive":
1199
+ lines.append(f"{prefix} nexo: recovery_policy=restart_daemon")
1200
+
1201
+ if record.get("run_at_load"):
1202
+ lines.append(f"{prefix} nexo: run_on_boot=true")
1203
+ return lines
1204
+
1205
+
1206
+ def _write_metadata_block(path: Path, metadata_lines: list[str]) -> None:
1207
+ raw = path.read_text(errors="ignore")
1208
+ lines = raw.splitlines(keepends=True)
1209
+ insert_at = 1 if lines and lines[0].startswith("#!") else 0
1210
+ filtered: list[str] = []
1211
+ for index, line in enumerate(lines):
1212
+ stripped = line.lstrip()
1213
+ if index >= insert_at and index < 25 and (
1214
+ stripped.startswith("# nexo:") or stripped.startswith("// nexo:")
1215
+ ):
1216
+ continue
1217
+ filtered.append(line)
1218
+ block = [line.rstrip("\n") + "\n" for line in metadata_lines]
1219
+ filtered[insert_at:insert_at] = block
1220
+ path.write_text("".join(filtered))
1221
+
1222
+
1223
+ def repair_orphan_personal_schedule_metadata(*, dry_run: bool = False) -> dict:
1224
+ """Infer inline metadata for personal LaunchAgents that predate the registry.
1225
+
1226
+ This powers ``nexo doctor --fix`` and ``nexo scripts reconcile`` for the
1227
+ legacy case where a user-owned LaunchAgent exists but the script has no
1228
+ declared schedule metadata. It never touches scripts outside the personal
1229
+ scripts directory.
1230
+ """
1231
+ classification = classify_scripts_dir()
1232
+ personal_scripts = [entry for entry in classification["entries"] if entry["classification"] == "personal"]
1233
+ scripts_by_path = {
1234
+ str(Path(entry["path"]).expanduser().resolve(strict=False)): entry
1235
+ for entry in personal_scripts
1236
+ }
1237
+ report = {
1238
+ "ok": True,
1239
+ "dry_run": dry_run,
1240
+ "repaired": [],
1241
+ "skipped": [],
1242
+ "errors": [],
1243
+ }
1244
+ for record in _discover_personal_schedule_records():
1245
+ script_path = str(record.get("script_path") or "")
1246
+ if not script_path:
1247
+ report["skipped"].append({"cron_id": record.get("cron_id", ""), "reason": "missing script path"})
1248
+ continue
1249
+ resolved_path = str(Path(script_path).expanduser().resolve(strict=False))
1250
+ script = scripts_by_path.get(resolved_path)
1251
+ if not script:
1252
+ report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "not a registered personal script"})
1253
+ continue
1254
+ declared = script.get("declared_schedule", {})
1255
+ if declared.get("required") and declared.get("valid"):
1256
+ report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "already has valid schedule metadata"})
1257
+ continue
1258
+ path = Path(script["path"])
1259
+ metadata_lines = _inferred_schedule_metadata_lines(path, script.get("metadata", {}), record)
1260
+ if not metadata_lines:
1261
+ report["skipped"].append({"cron_id": record.get("cron_id", ""), "path": script_path, "reason": "unsupported schedule metadata inference"})
1262
+ continue
1263
+ entry = {
1264
+ "cron_id": str(record.get("cron_id") or ""),
1265
+ "path": str(path),
1266
+ "schedule_type": str(record.get("schedule_type") or ""),
1267
+ }
1268
+ if dry_run:
1269
+ entry["dry_run"] = True
1270
+ report["repaired"].append(entry)
1271
+ continue
1272
+ try:
1273
+ _write_metadata_block(path, metadata_lines)
1274
+ report["repaired"].append(entry)
1275
+ except Exception as exc:
1276
+ report["errors"].append({"path": str(path), "error": str(exc)})
1277
+ if report["errors"]:
1278
+ report["ok"] = False
1279
+ return report
1280
+
1281
+
1071
1282
  def _extract_launchctl_value(output: str, prefixes: str | tuple[str, ...]) -> str | None:
1072
1283
  if isinstance(prefixes, str):
1073
1284
  prefixes = (prefixes,)
@@ -1605,12 +1816,14 @@ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
1605
1816
  def reconcile_personal_scripts(*, dry_run: bool = False) -> dict:
1606
1817
  """Full lifecycle reconciliation: classify, sync registry, ensure declared schedules."""
1607
1818
  renamed_result = rename_legacy_personal_script_filenames(dry_run=dry_run)
1819
+ orphan_metadata_result = repair_orphan_personal_schedule_metadata(dry_run=dry_run)
1608
1820
  sync_result = sync_personal_scripts()
1609
1821
  ensure_result = ensure_personal_schedules(dry_run=dry_run)
1610
1822
  return {
1611
1823
  "ok": True,
1612
1824
  "dry_run": dry_run,
1613
1825
  "renamed_legacy_filenames": renamed_result,
1826
+ "repaired_orphan_schedule_metadata": orphan_metadata_result,
1614
1827
  "sync": sync_result,
1615
1828
  "marker_warnings": sync_result.get("marker_warnings", []),
1616
1829
  "ensure_schedules": ensure_result,
@@ -6,7 +6,7 @@
6
6
  # nexo: cron_id=email-monitor
7
7
  # nexo: interval_seconds=60
8
8
  # nexo: schedule_required=true
9
- # nexo: timeout=21600
9
+ # nexo: timeout=1800
10
10
  # nexo: recovery_policy=run_once_on_wake
11
11
  # nexo: run_on_boot=false
12
12
  # nexo: run_on_wake=true
@@ -3,7 +3,7 @@
3
3
  # nexo: description=Continuous NEXO pending-work runner. Executes due followups, avoids overlap, and escalates operator attention through reminders/orchestrator.
4
4
  # nexo: category=automation
5
5
  # nexo: runtime=python
6
- # nexo: timeout=21600
6
+ # nexo: timeout=10800
7
7
  # nexo: cron_id=followup-runner
8
8
  # nexo: interval_seconds=3600
9
9
  # nexo: schedule_required=true
@@ -20,7 +20,7 @@ NEXO Followup Runner v8 — continuous pending-work runner.
20
20
  Role:
21
21
  1. Pick up due or recurring followups that should already be running.
22
22
  2. Process them through the real NEXO runtime and its MCP surface.
23
- 3. Avoid overlap via lock + long timeout (6h).
23
+ 3. Avoid overlap via lock + bounded timeout.
24
24
  4. Escalate operator attention through standard NEXO reminders when needed.
25
25
 
26
26
  From the operator's point of view, these are all "pending items". Internally,
@@ -58,6 +58,7 @@ from automation_controls import (
58
58
  get_send_reply_script_path,
59
59
  )
60
60
  from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
61
+ from constants import AUTOMATION_SUBPROCESS_TIMEOUT
61
62
  from core_prompts import render_core_prompt
62
63
  from operator_language import build_operator_language_contract, normalize_operator_language
63
64
  import db as nexo_db
@@ -69,7 +70,7 @@ LOG_FILE = LOG_DIR / "followup-runner.log"
69
70
  STATE_FILE = data_dir() / "followup-state.json"
70
71
  RESULTS_FILE = data_dir() / "followup-runner-results.json"
71
72
 
72
- CLI_TIMEOUT = 21600 # 6h safety net
73
+ CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
73
74
  LOCK_FILE = LOG_DIR / "followup-runner.lock"
74
75
  MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
75
76
  COOLDOWN_DAYS = 3 # Don't retry needs_decision/blocked for 3 days
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  """Shared tree hygiene helpers for runtime/install/release flows."""
4
4
 
5
5
  import re
6
+ import os
6
7
  from pathlib import Path
7
8
 
8
9
 
@@ -46,11 +47,11 @@ def find_duplicate_artifact_paths(root: str | Path) -> list[Path]:
46
47
  """Find duplicate copy artifacts under a tree, skipping generated/vendor directories."""
47
48
  root_path = Path(root).resolve()
48
49
  duplicates: list[Path] = []
49
- for path in sorted(root_path.rglob("*")):
50
- if any(part in _IGNORED_DIRS for part in path.parts):
51
- continue
52
- if not path.is_file():
53
- continue
54
- if is_duplicate_artifact_name(path):
55
- duplicates.append(path)
50
+ for dirpath, dirnames, filenames in os.walk(root_path):
51
+ dirnames[:] = sorted(name for name in dirnames if name not in _IGNORED_DIRS)
52
+ current = Path(dirpath)
53
+ for filename in sorted(filenames):
54
+ path = current / filename
55
+ if is_duplicate_artifact_name(path):
56
+ duplicates.append(path)
56
57
  return duplicates