nexo-brain 7.1.8 → 7.2.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.
@@ -84,37 +84,67 @@ _THIS_DIR = Path(__file__).resolve().parent
84
84
  CODE_ROOT = _THIS_DIR.parent
85
85
  _REPO_CANDIDATE = CODE_ROOT.parent
86
86
 
87
+ # B10 mini-refactor (AUDITOR-V700-PASS2 §11): the pieces below that look up
88
+ # env-dependent or filesystem-dependent state now go through lazy helpers so
89
+ # tests can monkeypatch NEXO_HOME / the .git location after the module was
90
+ # imported without snapshotting a stale value. The module-level constants
91
+ # (NEXO_HOME, DATA_DIR, BACKUP_BASE, REPO_DIR, SRC_DIR, PACKAGE_JSON) are
92
+ # kept for the existing 77 callsites; they will migrate to the helpers in
93
+ # followup NF-B10-UPDATE-PY-LAZY-CALLSITES-COSMETIC post-release.
94
+
95
+
96
+ def _nexo_home() -> Path:
97
+ """Resolve the current NEXO_HOME on every call.
98
+
99
+ export_resolved_nexo_home() reads the env var + runtime config each time,
100
+ so fixtures that monkeypatch NEXO_HOME mid-test pick up the new path.
101
+ """
102
+ return export_resolved_nexo_home()
103
+
104
+
105
+ def _is_packaged_install() -> bool:
106
+ """Return True iff we are running inside a packaged NEXO install.
107
+
108
+ Evaluated on every call because tests may stage a fake repo (or drop a
109
+ synthetic .git marker) after the module has already been imported.
110
+ """
111
+ return not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
112
+
113
+
87
114
  NEXO_HOME = export_resolved_nexo_home()
88
115
  DATA_DIR = paths.data_dir()
89
116
  BACKUP_BASE = paths.backups_dir()
90
117
 
91
118
  # In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
92
- _PACKAGED_INSTALL = not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
119
+ _PACKAGED_INSTALL = _is_packaged_install()
93
120
  REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
94
121
  SRC_DIR = CODE_ROOT
95
122
  PACKAGE_JSON = REPO_DIR / "package.json"
96
123
 
97
124
 
98
- def _venv_python_path(runtime_root: Path = NEXO_HOME) -> Path:
125
+ def _venv_python_path(runtime_root: Path | None = None) -> Path:
126
+ root = runtime_root or _nexo_home()
99
127
  if sys.platform == "win32":
100
- return runtime_root / ".venv" / "Scripts" / "python.exe"
101
- return runtime_root / ".venv" / "bin" / "python3"
128
+ return root / ".venv" / "Scripts" / "python.exe"
129
+ return root / ".venv" / "bin" / "python3"
102
130
 
103
131
 
104
- def _venv_pip_path(runtime_root: Path = NEXO_HOME) -> Path:
132
+ def _venv_pip_path(runtime_root: Path | None = None) -> Path:
133
+ root = runtime_root or _nexo_home()
105
134
  if sys.platform == "win32":
106
- return runtime_root / ".venv" / "Scripts" / "pip.exe"
107
- return runtime_root / ".venv" / "bin" / "pip"
135
+ return root / ".venv" / "Scripts" / "pip.exe"
136
+ return root / ".venv" / "bin" / "pip"
108
137
 
109
138
 
110
- def _ensure_managed_venv(runtime_root: Path = NEXO_HOME) -> str | None:
111
- venv_python = _venv_python_path(runtime_root)
139
+ def _ensure_managed_venv(runtime_root: Path | None = None) -> str | None:
140
+ root = runtime_root or _nexo_home()
141
+ venv_python = _venv_python_path(root)
112
142
  if venv_python.exists():
113
143
  return None
114
144
  try:
115
- runtime_root.mkdir(parents=True, exist_ok=True)
145
+ root.mkdir(parents=True, exist_ok=True)
116
146
  result = subprocess.run(
117
- [sys.executable, "-m", "venv", str(runtime_root / ".venv")],
147
+ [sys.executable, "-m", "venv", str(root / ".venv")],
118
148
  capture_output=True,
119
149
  text=True,
120
150
  timeout=120,
@@ -173,7 +203,7 @@ def _runtime_code_root(runtime_root: Path | None = None) -> Path:
173
203
 
174
204
  def _core_artifact_source_dir() -> Path | None:
175
205
  """Return the canonical source directory for packaged core artifacts."""
176
- if _PACKAGED_INSTALL:
206
+ if _is_packaged_install():
177
207
  return _find_npm_pkg_src()
178
208
  return SRC_DIR
179
209
 
@@ -241,7 +271,7 @@ def _cleanup_retired_runtime_files() -> list[str]:
241
271
 
242
272
  def _read_version() -> str:
243
273
  """Read the installed/runtime version."""
244
- if _PACKAGED_INSTALL:
274
+ if _is_packaged_install():
245
275
  # version.json is the runtime truth for packaged installs.
246
276
  try:
247
277
  version_file = NEXO_HOME / "version.json"
@@ -286,7 +316,7 @@ def _requirements_hash() -> str:
286
316
  """Return a content hash of requirements.txt, or empty string if missing."""
287
317
  import hashlib
288
318
  req_file = SRC_DIR / "requirements.txt"
289
- if not req_file.exists() and _PACKAGED_INSTALL:
319
+ if not req_file.exists() and _is_packaged_install():
290
320
  npm_src = _find_npm_pkg_src()
291
321
  if npm_src:
292
322
  req_file = npm_src / "requirements.txt"
@@ -441,7 +471,7 @@ def _restore_databases(backup_dir: str):
441
471
  def _reinstall_pip_deps() -> str | None:
442
472
  """Reinstall Python dependencies from requirements.txt into the managed venv."""
443
473
  req_file = SRC_DIR / "requirements.txt"
444
- if not req_file.exists() and _PACKAGED_INSTALL:
474
+ if not req_file.exists() and _is_packaged_install():
445
475
  # In packaged mode, requirements.txt lives in the npm package's src/ dir
446
476
  npm_src = _find_npm_pkg_src()
447
477
  if npm_src:
@@ -714,7 +744,7 @@ def _format_dep_results(dep_results: list[dict]) -> list[str]:
714
744
  def _run_migrations() -> str | None:
715
745
  """Run init_db() to apply pending migrations. Returns error or None."""
716
746
  # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
717
- cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
747
+ cwd = str(NEXO_HOME) if _is_packaged_install() else str(SRC_DIR)
718
748
  try:
719
749
  result = subprocess.run(
720
750
  [sys.executable, "-c", "import db; db.init_db()"],
@@ -733,7 +763,7 @@ def _run_migrations() -> str | None:
733
763
  def _verify_import() -> str | None:
734
764
  """Verify server.py can be imported successfully."""
735
765
  # In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
736
- cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
766
+ cwd = str(NEXO_HOME) if _is_packaged_install() else str(SRC_DIR)
737
767
  try:
738
768
  result = subprocess.run(
739
769
  [sys.executable, "-c", "import server"],
@@ -71,6 +71,43 @@ def _parse_json_object(value: str) -> dict | None:
71
71
  return parsed if isinstance(parsed, dict) else None
72
72
 
73
73
 
74
+ def _checkpoint_active_files(*payloads: dict | None) -> list[str]:
75
+ seen: list[str] = []
76
+ for payload in payloads:
77
+ if not isinstance(payload, dict):
78
+ continue
79
+ for key in ("active_files", "files", "tracked_files"):
80
+ raw = payload.get(key)
81
+ if raw is None:
82
+ continue
83
+ if isinstance(raw, str):
84
+ items = [item.strip() for item in raw.split(",") if item.strip()]
85
+ elif isinstance(raw, (list, tuple, set)):
86
+ items = [str(item).strip() for item in raw if str(item).strip()]
87
+ else:
88
+ items = [str(raw).strip()] if str(raw).strip() else []
89
+ for item in items:
90
+ if item and item not in seen:
91
+ seen.append(item)
92
+ return seen
93
+
94
+
95
+ def _workflow_blocker_text(
96
+ *,
97
+ step_status: str,
98
+ run_status: str,
99
+ summary: str,
100
+ next_action: str,
101
+ requires_approval: bool,
102
+ ) -> str:
103
+ status_tokens = {str(step_status or "").strip().lower(), str(run_status or "").strip().lower()}
104
+ if "blocked" in status_tokens:
105
+ return summary or next_action or "Workflow blocked."
106
+ if requires_approval or "waiting_approval" in status_tokens:
107
+ return summary or next_action or "Workflow waiting for approval."
108
+ return ""
109
+
110
+
74
111
  def handle_workflow_open(
75
112
  sid: str,
76
113
  goal: str,
@@ -387,6 +424,34 @@ def handle_workflow_update(
387
424
  "attempt_count": resume["next_step"].get("attempt_count", 0),
388
425
  "max_retries": resume["next_step"].get("max_retries", 0),
389
426
  }
427
+ try:
428
+ from checkpoint_policy import record_milestone
429
+
430
+ durable_checkpoint = record_milestone(
431
+ run.get("session_id", ""),
432
+ reason=f"workflow:{(step_key or run.get('current_step_key') or 'update')}",
433
+ task=run.get("goal", ""),
434
+ task_status=("blocked" if run["status"] in {"blocked", "waiting_approval"} else "active"),
435
+ active_files=_checkpoint_active_files(
436
+ _parse_json_object(shared_state) if str(shared_state).strip() else None,
437
+ _parse_json_object(state_patch) if str(state_patch).strip() else None,
438
+ run.get("shared_state") if isinstance(run.get("shared_state"), dict) else None,
439
+ ),
440
+ current_goal=run.get("goal", ""),
441
+ decisions_summary=(summary or checkpoint_label or step_title or step_key or run.get("last_checkpoint_label", "")),
442
+ blockers=_workflow_blocker_text(
443
+ step_status=step_status,
444
+ run_status=run["status"],
445
+ summary=summary,
446
+ next_action=next_action or run.get("next_action", ""),
447
+ requires_approval=requires_approval,
448
+ ),
449
+ reasoning_thread=(evidence or compensation or "").strip(),
450
+ next_step=(next_action or run.get("next_action", "")),
451
+ )
452
+ response["durable_checkpoint"] = durable_checkpoint
453
+ except Exception:
454
+ pass
390
455
  return json.dumps(response, ensure_ascii=False, indent=2)
391
456
 
392
457
 
@@ -42,11 +42,57 @@ VALID_STATUSES = {
42
42
  STATUS_OFF,
43
43
  }
44
44
 
45
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
46
- CONTRIB_ROOT = NEXO_HOME / "contrib" / "public-core"
47
- CONTRIB_REPO_DIR = CONTRIB_ROOT / "repo"
48
- CONTRIB_WORKTREES_DIR = CONTRIB_ROOT / "worktrees"
49
- CONTRIB_ARTIFACTS_DIR = paths.operations_dir() / "public-contrib"
45
+ # Path resolution moved to lazy functions (AUDITOR-V700-PASS2 §11, B10 item
46
+ # 3). The previous module-level constants were evaluated at import time, so
47
+ # any caller that monkeypatched NEXO_HOME or ``paths.operations_dir()`` after
48
+ # import kept seeing the stale values. PEP 562 ``__getattr__`` below still
49
+ # exposes ``public_contribution.NEXO_HOME`` / ``CONTRIB_ROOT`` / etc. for
50
+ # legacy callers that access them as module attributes — re-evaluated on
51
+ # every access. A call like ``from public_contribution import CONTRIB_ROOT``
52
+ # still snapshots at import, which is the intended behaviour for scripts
53
+ # whose NEXO_HOME is fixed before they start.
54
+
55
+
56
+ def _nexo_home() -> Path:
57
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
58
+
59
+
60
+ # Public-contribution staging lives under ``NEXO_HOME / contrib`` (the mirror
61
+ # clone of the public repo plus per-proposal worktrees). It is intentionally
62
+ # outside ``paths.operations_dir()`` because it holds an actual git clone, not
63
+ # operational artifacts. Artifacts (logs, proposal payloads) live under
64
+ # ``_contrib_artifacts_dir()`` below. If this ever relocates, migrate the
65
+ # existing clone + open worktrees — do NOT delete and reclone blindly.
66
+ def _contrib_root() -> Path:
67
+ return _nexo_home() / "contrib" / "public-core"
68
+
69
+
70
+ def _contrib_repo_dir() -> Path:
71
+ return _contrib_root() / "repo"
72
+
73
+
74
+ def _contrib_worktrees_dir() -> Path:
75
+ return _contrib_root() / "worktrees"
76
+
77
+
78
+ def _contrib_artifacts_dir() -> Path:
79
+ return paths.operations_dir() / "public-contrib"
80
+
81
+
82
+ _LAZY_PATHS = {
83
+ "NEXO_HOME": _nexo_home,
84
+ "CONTRIB_ROOT": _contrib_root,
85
+ "CONTRIB_REPO_DIR": _contrib_repo_dir,
86
+ "CONTRIB_WORKTREES_DIR": _contrib_worktrees_dir,
87
+ "CONTRIB_ARTIFACTS_DIR": _contrib_artifacts_dir,
88
+ }
89
+
90
+
91
+ def __getattr__(name: str):
92
+ resolver = _LAZY_PATHS.get(name)
93
+ if resolver is None:
94
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
95
+ return resolver()
50
96
 
51
97
 
52
98
  def _utcnow() -> datetime:
@@ -26,11 +26,29 @@ and this module decides whether to inject.
26
26
  from __future__ import annotations
27
27
 
28
28
  import re
29
- from typing import Callable, Iterable, Optional
29
+ from typing import Any, Callable, Iterable, Optional
30
30
 
31
31
  from core_prompts import render_core_prompt
32
32
 
33
33
 
34
+ def _verdict_to_bool(verdict: Any) -> bool:
35
+ """Normalize a classifier verdict to an inject/no-inject decision.
36
+
37
+ The shared ``enforcement_classifier.classify`` exposes a ``tristate``
38
+ mode that returns ``"yes"``/``"no"``/``"unknown"`` strings. A naive
39
+ ``bool(verdict)`` wrap treats ``"unknown"`` (and any non-empty string
40
+ the model may produce) as truthy, which is fail-OPEN for R34. Only
41
+ a real ``True`` or an explicit ``"yes"`` should trigger an injection;
42
+ every other shape — ``False``, ``None``, ``"no"``, ``"unknown"``,
43
+ arbitrary strings — is conservative no-inject.
44
+ """
45
+ if isinstance(verdict, bool):
46
+ return verdict
47
+ if isinstance(verdict, str):
48
+ return verdict.strip().lower() == "yes"
49
+ return False
50
+
51
+
34
52
  # Shared-brain tools that count as "you checked before speaking".
35
53
  SHARED_BRAIN_TOOLS = frozenset({
36
54
  "nexo_recent_context",
@@ -79,7 +97,7 @@ def should_inject_r34(
79
97
  message: str,
80
98
  *,
81
99
  recent_tool_names: Iterable[str] | None,
82
- classifier: Callable[[str, str], bool] | None = None,
100
+ classifier: Callable[[str, str], Any] | None = None,
83
101
  ) -> tuple[bool, str, str]:
84
102
  """Return ``(inject, prompt, matched_text)``.
85
103
 
@@ -88,9 +106,11 @@ def should_inject_r34(
88
106
  recent_tool_names: tool names seen in this turn; any match in
89
107
  SHARED_BRAIN_TOOLS suppresses the rule.
90
108
  classifier: optional LLM classifier. Signature
91
- ``classifier(question, context) -> bool``. If provided, the
92
- rule fires only when classifier says "yes". If None, the
93
- regex match alone fires (test path).
109
+ ``classifier(question, context) -> bool | str``. Return ``True``
110
+ or the string ``"yes"`` to trigger injection; any other value
111
+ (``False``, ``None``, ``"no"``, ``"unknown"``, arbitrary
112
+ strings) is treated as no-inject. If ``None`` is passed for
113
+ the argument, the regex match alone fires (test path).
94
114
  """
95
115
  if not isinstance(message, str) or not message:
96
116
  return False, "", ""
@@ -103,15 +123,17 @@ def should_inject_r34(
103
123
  if classifier is None:
104
124
  return True, INJECTION_PROMPT, matched
105
125
  # LLM disambiguation — reuses T4 infra. The engine passes a lambda
106
- # that calls enforcement_classifier.classify under the hood.
126
+ # that calls enforcement_classifier.classify under the hood. Parse
127
+ # the verdict via _verdict_to_bool so tristate "unknown" does not
128
+ # coerce to True.
107
129
  try:
108
- verdict = bool(classifier(CLASSIFIER_QUESTION, message))
130
+ raw_verdict = classifier(CLASSIFIER_QUESTION, message)
109
131
  except Exception:
110
132
  # Fail-closed: if the classifier errors, do not inject (avoids
111
133
  # noisy false positives on regex-only matches when the LLM is
112
134
  # unavailable).
113
135
  return False, "", matched
114
- if not verdict:
136
+ if not _verdict_to_bool(raw_verdict):
115
137
  return False, "", matched
116
138
  return True, INJECTION_PROMPT, matched
117
139
 
@@ -121,5 +143,6 @@ __all__ = [
121
143
  "CLASSIFIER_QUESTION",
122
144
  "INJECTION_PROMPT",
123
145
  "SHARED_BRAIN_TOOLS",
146
+ "_verdict_to_bool",
124
147
  "should_inject_r34",
125
148
  ]
@@ -707,7 +707,14 @@ def classify_scripts_dir() -> dict:
707
707
  core_names = load_core_script_names()
708
708
  core_identities = load_core_script_identities()
709
709
  entries: list[dict] = []
710
- seen_keys: set[tuple[str, str]] = set()
710
+ # Dedup by resolved real path so the same physical file surfaced
711
+ # from two candidate dirs (e.g. core/scripts/foo.sh + the F0.6
712
+ # legacy fallback ~/.nexo/scripts/foo.sh pointing at the same inode
713
+ # via symlink) appears once. Two distinct files that happen to share
714
+ # a filename resolve to different paths and both survive — preserves
715
+ # the D2 audit fix without the cosmetic duplicate the AUDITOR-V700-
716
+ # PASS2 §5 transitional window flagged.
717
+ seen_real_paths: set[str] = set()
711
718
  for sdir in candidate_dirs:
712
719
  if not sdir.is_dir():
713
720
  continue
@@ -725,12 +732,13 @@ def classify_scripts_dir() -> dict:
725
732
  for f in sorted(sdir.iterdir()):
726
733
  if not f.is_file():
727
734
  continue
728
- # Dedup by (name, dir-classification) so a personal script with
729
- # the same filename as a core script doesn't disappear (D2 audit fix).
730
- dedup_key = (f.name, dir_classification or "legacy")
731
- if dedup_key in seen_keys:
735
+ try:
736
+ real_path = str(f.resolve(strict=False))
737
+ except OSError:
738
+ real_path = str(f)
739
+ if real_path in seen_real_paths:
732
740
  continue
733
- seen_keys.add(dedup_key)
741
+ seen_real_paths.add(real_path)
734
742
  meta = parse_inline_metadata(f)
735
743
  if _is_ignored(f):
736
744
  entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
@@ -55,9 +55,15 @@ log() { echo "[$TS] $1" >> "$LOG"; }
55
55
  # The NEXO_CODE env var must point to the repo src/ directory.
56
56
  # Add personal (non-manifest) monitors to PERSONAL_MONITORS below.
57
57
  NEXO_CODE="${NEXO_CODE:-$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)}"
58
- # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
58
+ # Manifest resolution priority:
59
+ # 1. $NEXO_HOME/runtime/crons/manifest.json — F0.6 canonical location
60
+ # 2. $NEXO_HOME/crons/manifest.json — pre-F0.6 legacy (kept so
61
+ # half-migrated installs don't silently lose all core monitors)
62
+ # 3. $NEXO_CODE/crons/manifest.json — dev/repo checkout
59
63
  if [ -f "$NEXO_HOME/runtime/crons/manifest.json" ]; then
60
64
  MANIFEST_FILE="$NEXO_HOME/runtime/crons/manifest.json"
65
+ elif [ -f "$NEXO_HOME/crons/manifest.json" ]; then
66
+ MANIFEST_FILE="$NEXO_HOME/crons/manifest.json"
61
67
  else
62
68
  MANIFEST_FILE="$NEXO_CODE/crons/manifest.json"
63
69
  fi