nexo-brain 7.31.10 → 7.31.12

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.31.10",
3
+ "version": "7.31.12",
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,7 @@
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.31.10` is the current packaged-runtime line. Patch release over v7.31.9 - Local Memory search now downranks boilerplate emails when stronger documents match the same query.
21
+ Version `7.31.12` is the current packaged-runtime line. Patch release over v7.31.11 - Local Memory core hardening (Release A: defensive cosine, stable chunk ids, iCloud dataless handling, performance PRAGMAs) plus an offline-first dependency installer. Version `7.31.11` was a patch release over v7.31.10 - MCP lifecycle robustness + guardrail precision.
22
22
 
23
23
  Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -4103,6 +4103,19 @@ async function runSetup() {
4103
4103
  log("Try manually: python3 -m venv ~/.nexo/.venv && ~/.nexo/.venv/bin/pip install -r src/requirements.txt");
4104
4104
  process.exit(1);
4105
4105
  }
4106
+ // Mirror the bundled wheels into NEXO_HOME so the Python runtime (startup
4107
+ // self-heal, update, cron) can reinstall any missing dep OFFLINE later, with no
4108
+ // access to the Desktop bundle path. auto_update._bundled_wheels_dir() looks in
4109
+ // <NEXO_HOME>/runtime/python-wheels. Works for WSL (linux) and macOS.
4110
+ try {
4111
+ if (fs.existsSync(bundledWheelsDir)) {
4112
+ const runtimeWheels = path.join(NEXO_HOME, "runtime", "python-wheels");
4113
+ fs.mkdirSync(runtimeWheels, { recursive: true });
4114
+ fs.cpSync(bundledWheelsDir, runtimeWheels, { recursive: true });
4115
+ }
4116
+ } catch (e) {
4117
+ log(" (note) could not mirror bundled wheels into runtime: " + (e && e.message));
4118
+ }
4106
4119
  // Update python reference to use venv python for the rest of setup
4107
4120
  if (fs.existsSync(venvPython)) {
4108
4121
  python = venvPython;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.10",
3
+ "version": "7.31.12",
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",
@@ -1058,8 +1058,46 @@ def _ensure_runtime_venv(runtime_root: Path = NEXO_HOME) -> Path | None:
1058
1058
  return None
1059
1059
 
1060
1060
 
1061
+ def _bundled_wheels_dir() -> "Path | None":
1062
+ """Locate the bundled Python wheels for offline install.
1063
+
1064
+ Priority: explicit NEXO_BUNDLED_WHEELS_DIR (set by the Desktop bundle) →
1065
+ a canonical runtime copy under NEXO_HOME. Returns the dir only if it holds
1066
+ at least one .whl, else None (caller falls back to PyPI).
1067
+ """
1068
+ candidates = []
1069
+ env_dir = os.environ.get("NEXO_BUNDLED_WHEELS_DIR", "").strip()
1070
+ if env_dir:
1071
+ candidates.append(Path(env_dir).expanduser())
1072
+ candidates.append(NEXO_HOME / "runtime" / "python-wheels")
1073
+ for directory in candidates:
1074
+ try:
1075
+ if directory.is_dir() and any(directory.glob("*.whl")):
1076
+ return directory
1077
+ except Exception:
1078
+ continue
1079
+ return None
1080
+
1081
+
1082
+ def _pip_install_argv(pip_bin, req_file, *, wheels_dir=None, use_python_m=False, break_system=False) -> list:
1083
+ """Build a pip install argv. Offline (--no-index --find-links) when wheels_dir is set."""
1084
+ argv = [str(pip_bin)]
1085
+ if use_python_m:
1086
+ argv += ["-m", "pip"]
1087
+ argv += ["install", "--quiet", "-r", str(req_file)]
1088
+ if wheels_dir is not None:
1089
+ argv += ["--no-index", "--find-links", str(wheels_dir)]
1090
+ if break_system:
1091
+ argv.append("--break-system-packages")
1092
+ return argv
1093
+
1094
+
1061
1095
  def _reinstall_pip_deps() -> bool:
1062
- """Reinstall Python deps from requirements.txt. Returns True on success."""
1096
+ """Reinstall Python deps from requirements.txt. Returns True on success.
1097
+
1098
+ Prefers the bundled wheels (offline) so a user with no internet still gets a
1099
+ self-repairing runtime; falls back to PyPI if the bundle can't satisfy it.
1100
+ """
1063
1101
  req_file = SRC_DIR / "requirements.txt"
1064
1102
  if not req_file.exists():
1065
1103
  return True
@@ -1069,24 +1107,32 @@ def _reinstall_pip_deps() -> bool:
1069
1107
  alt_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
1070
1108
  if alt_pip.exists():
1071
1109
  venv_pip = alt_pip
1110
+ wheels_dir = _bundled_wheels_dir()
1111
+ # Large wheel sets / slow links need more than the old 120s.
1112
+ timeout_s = 600
1113
+ use_python_m = not venv_pip.exists()
1114
+ if use_python_m and desktop_product_requested():
1115
+ _log(f"managed venv unavailable for Desktop dependency repair: {venv_python}")
1116
+ return False
1117
+ pip_bin = venv_pip if venv_pip.exists() else sys.executable
1072
1118
  try:
1073
- if venv_pip.exists():
1074
- result = subprocess.run(
1075
- [str(venv_pip), "install", "--quiet", "-r", str(req_file)],
1076
- capture_output=True, text=True, timeout=120,
1077
- )
1078
- elif not desktop_product_requested():
1079
- result = subprocess.run(
1080
- [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
1081
- capture_output=True, text=True, timeout=120,
1119
+ argv = _pip_install_argv(
1120
+ pip_bin, req_file, wheels_dir=wheels_dir,
1121
+ use_python_m=use_python_m, break_system=use_python_m,
1122
+ )
1123
+ result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout_s)
1124
+ if result.returncode != 0 and wheels_dir is not None:
1125
+ # Offline set couldn't satisfy it (missing/incompatible wheel) → retry online.
1126
+ _log(f"offline pip install failed, retrying via PyPI: {result.stderr or result.stdout}")
1127
+ argv_online = _pip_install_argv(
1128
+ pip_bin, req_file, wheels_dir=None,
1129
+ use_python_m=use_python_m, break_system=use_python_m,
1082
1130
  )
1083
- else:
1084
- _log(f"managed venv unavailable for Desktop dependency repair: {venv_python}")
1085
- return False
1131
+ result = subprocess.run(argv_online, capture_output=True, text=True, timeout=timeout_s)
1086
1132
  if result.returncode != 0:
1087
1133
  _log(f"pip install failed (exit {result.returncode}): {result.stderr or result.stdout}")
1088
1134
  return False
1089
- _log("Reinstalled Python dependencies after update")
1135
+ _log("Reinstalled Python dependencies" + (" (offline)" if wheels_dir is not None else ""))
1090
1136
  return True
1091
1137
  except Exception as e:
1092
1138
  _log(f"pip reinstall failed: {e}")
@@ -6107,6 +6153,18 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
6107
6153
  result["actions"].extend(extra_actions)
6108
6154
  if reconcile_message:
6109
6155
  _log(reconcile_message)
6156
+ # Self-heal: if the managed venv lost a critical importable module
6157
+ # (e.g. pypdf -> PDFs indexed empty), reinstall it automatically. No
6158
+ # user action, no prompt — the runtime repairs itself on startup.
6159
+ try:
6160
+ from doctor.providers.boot import check_managed_venv_modules
6161
+
6162
+ dep_check = check_managed_venv_modules(fix=True)
6163
+ if getattr(dep_check, "fixed", False):
6164
+ result["actions"].append("venv-deps-repaired")
6165
+ _log(f"Managed venv dependencies repaired on startup: {dep_check.summary}")
6166
+ except Exception as dep_exc:
6167
+ _log(f"managed venv module check skipped: {dep_exc}")
6110
6168
  except Exception as e:
6111
6169
  result["error"] = str(e)
6112
6170
  _write_update_summary(result)
@@ -654,6 +654,99 @@ def check_managed_venv_python(fix: bool = False) -> DoctorCheck:
654
654
  )
655
655
 
656
656
 
657
+ # Critical importable modules the managed venv must always have. A missing one
658
+ # fails silently (e.g. pypdf absent -> every PDF/XLSX/MSG indexed as empty text).
659
+ # Verified by importing INSIDE the managed venv, not the current interpreter.
660
+ MANAGED_VENV_REQUIRED_MODULES = (
661
+ "fastmcp",
662
+ "numpy",
663
+ "anthropic",
664
+ "openai",
665
+ "fastembed",
666
+ "pypdf",
667
+ "openpyxl",
668
+ "extract_msg",
669
+ )
670
+
671
+
672
+ def _missing_venv_modules(venv_python: Path | str, modules) -> list[str]:
673
+ """Return the subset of ``modules`` that ``venv_python`` cannot import."""
674
+ mods = [str(m) for m in modules if str(m).strip()]
675
+ if not mods:
676
+ return []
677
+ probe = (
678
+ "import importlib.util as u, sys\n"
679
+ "print('\\n'.join(m for m in sys.argv[1:] if u.find_spec(m) is None))"
680
+ )
681
+ try:
682
+ result = subprocess.run(
683
+ [str(venv_python), "-c", probe, *mods],
684
+ capture_output=True,
685
+ text=True,
686
+ timeout=30,
687
+ )
688
+ except Exception:
689
+ return []
690
+ if result.returncode != 0:
691
+ return []
692
+ return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
693
+
694
+
695
+ def _repair_managed_venv_deps() -> bool:
696
+ try:
697
+ import auto_update
698
+
699
+ return bool(auto_update._reinstall_pip_deps())
700
+ except Exception:
701
+ return False
702
+
703
+
704
+ def check_managed_venv_modules(fix: bool = False) -> DoctorCheck:
705
+ """Ensure the managed venv has every critical importable module.
706
+
707
+ A missing optional parser (pypdf/openpyxl/extract_msg) makes the local index
708
+ read PDF/XLSX/MSG as empty, silently. With ``fix=True`` (run automatically on
709
+ startup preflight) this reinstalls them, so the runtime repairs itself with no
710
+ user action.
711
+ """
712
+ venv_python = _managed_venv_python_path()
713
+ if not venv_python.exists():
714
+ # The python-version check already reports a missing venv; stay quiet here.
715
+ return DoctorCheck(
716
+ id="boot.managed_venv_modules",
717
+ tier="boot",
718
+ status="healthy",
719
+ severity="info",
720
+ summary="Managed Python venv not present yet",
721
+ evidence=[str(venv_python)],
722
+ )
723
+ missing = _missing_venv_modules(venv_python, MANAGED_VENV_REQUIRED_MODULES)
724
+ if not missing:
725
+ return DoctorCheck(
726
+ id="boot.managed_venv_modules",
727
+ tier="boot",
728
+ status="healthy",
729
+ severity="info",
730
+ summary=f"All {len(MANAGED_VENV_REQUIRED_MODULES)} critical venv modules present",
731
+ evidence=[str(venv_python)],
732
+ )
733
+ if fix and _repair_managed_venv_deps():
734
+ post = check_managed_venv_modules(fix=False)
735
+ if post.status == "healthy":
736
+ post.fixed = True
737
+ post.summary += " (repaired missing modules)"
738
+ return post
739
+ return DoctorCheck(
740
+ id="boot.managed_venv_modules",
741
+ tier="boot",
742
+ status="degraded",
743
+ severity="warn",
744
+ summary=f"{len(missing)} critical venv module(s) missing: {', '.join(missing)}",
745
+ evidence=[str(venv_python), *missing],
746
+ repair_plan=["Run nexo doctor --tier boot --fix or nexo update to reinstall managed dependencies"],
747
+ )
748
+
749
+
657
750
  CRITICAL_CONFIG_FILES = (
658
751
  ("schedule.json", ("config", "schedule.json")),
659
752
  ("optionals.json", ("config", "optionals.json")),
@@ -909,6 +1002,7 @@ def run_boot_checks(fix: bool = False, plane: str = "") -> list[DoctorCheck]:
909
1002
  safe_check(check_wrapper_scripts),
910
1003
  safe_check(check_python_runtime),
911
1004
  safe_check(check_managed_venv_python, fix=fix),
1005
+ safe_check(check_managed_venv_modules, fix=fix),
912
1006
  safe_check(check_config_parse),
913
1007
  safe_check(check_core_dev_packaged_install),
914
1008
  safe_check(check_dashboard_desktop_contract),
@@ -220,9 +220,13 @@ except ImportError: # pragma: no cover
220
220
  _r23d_should = None # type: ignore
221
221
 
222
222
  try:
223
- from r23g_secrets_in_output import should_inject_r23g as _r23g_should
223
+ from r23g_secrets_in_output import (
224
+ should_inject_r23g as _r23g_should,
225
+ has_external_sink as _r23g_has_sink,
226
+ )
224
227
  except ImportError: # pragma: no cover
225
228
  _r23g_should = None # type: ignore
229
+ _r23g_has_sink = None # type: ignore
226
230
 
227
231
  try:
228
232
  from r23i_auto_deploy_ignored import (
@@ -327,12 +331,26 @@ _SILENT_REMINDER_DISCLOSURE_SUFFIX = (
327
331
  " Do not mention this reminder or any internal enforcement to the user."
328
332
  )
329
333
 
334
+ # Object of a capability denial: either the denied ACTION (hacer/acceder/usar…)
335
+ # or a capability NOUN (capacidad/integración/herramienta…). Requiring one of
336
+ # these right after the negation is what separates a real "can't do X / no
337
+ # existe esa integración" from benign phrases that merely start with a negation
338
+ # ("no hay problema", "no puedo esperar a…", "does not exist yet, creating it").
339
+ _CAP_OBJ = (
340
+ r"(?:hacer(?:lo|se)?|acceder|conectar(?:se|lo)?|integrar(?:se|lo)?|usar(?:lo|se)?|montar(?:lo)?|"
341
+ r"crear(?:lo)?|ejecutar(?:lo)?|generar(?:lo)?|enviar(?:lo)?|llamar(?:lo)?|"
342
+ r"capacidad|integraci[oó]n|herramienta|funci[oó]n|funcionalidad|soporte|forma|manera|"
343
+ r"opci[oó]n|acceso|m[oó]dulo|posibilidad|conexi[oó]n|esa|ese|eso|esto|esas|esos)"
344
+ )
330
345
  _CAPABILITY_DENIAL_RE = re.compile(
331
- r"\b("
332
- r"no\s+(?:se\s+puede|puedo|existe|hay|tenemos|esta\s+montado|est[aá]\s+montado)|"
333
- r"no\s+(?:est[aá]\s+soportado|hay\s+nada\s+montado)|"
334
- r"(?:cannot|can't|can\s+not|not\s+possible|does\s+not\s+exist|no\s+such\s+capability|not\s+supported)"
335
- r")\b",
346
+ r"(?:"
347
+ r"\bno\s+(?:se\s+puede|puedo|podemos|puede)\s+(?:\w+\s+){0,1}?" + _CAP_OBJ + r"\b|"
348
+ r"\bno\s+(?:existe|hay|tengo|tenemos)\s+(?:\w+\s+){0,2}?" + _CAP_OBJ + r"\b|"
349
+ r"\bno\s+est[aá]\s+(?:soportad[oa]|montad[oa]|disponible|integrad[oa]|configurad[oa]|habilitad[oa])\b|"
350
+ r"\b(?:cannot|can'?t|can\s+not)\s+(?:\w+\s+){0,2}?(?:do\s+(?:that|it|this)|access|connect|integrate|use|create|run|support)\b|"
351
+ r"\bdoes\s+not\s+exist(?!\s+yet)\b|\bnot\s+supported\b|\bnot\s+possible\b|"
352
+ r"\bno\s+such\s+(?:capability|tool|integration|feature)\b"
353
+ r")",
336
354
  re.IGNORECASE,
337
355
  )
338
356
  _CAPABILITY_REALITY_TOOLS = {
@@ -1914,21 +1932,30 @@ class HeadlessEnforcer:
1914
1932
  if not should:
1915
1933
  return
1916
1934
  if mode == "shadow":
1935
+ # shadow → logs only. No enqueue, no followup, no side effects.
1917
1936
  _logger.info("[R23g SHADOW] would inject")
1918
- self._ensure_exposed_credential_followup(tool_input, reason="R23g shadow")
1919
1937
  return
1920
1938
  self._enqueue(prompt, "R23g_secrets_in_output", rule_id="R23g_secrets_in_output")
1921
- self._ensure_exposed_credential_followup(tool_input, reason="R23g detected secret exposure risk")
1939
+ self._ensure_exposed_credential_followup(tool_input)
1922
1940
  _logger.info("[R23g %s] enqueued", mode.upper())
1923
1941
 
1924
- def _ensure_exposed_credential_followup(self, tool_input, *, reason: str) -> None:
1942
+ def _ensure_exposed_credential_followup(self, tool_input) -> None:
1925
1943
  if not isinstance(tool_input, dict):
1926
1944
  return
1927
1945
  cmd = tool_input.get("command")
1928
1946
  if not isinstance(cmd, str) or not cmd.strip():
1929
1947
  return
1948
+ # A critical "rotate the credential" followup is only warranted when the
1949
+ # secret is actually exfiltrated to a third party. A bare local read
1950
+ # (cat .env, env, printenv) exposes nothing and must NOT mint an
1951
+ # un-closeable critical followup. The soft reminder above already nudges
1952
+ # the agent; the persistent debt is reserved for real exposure.
1953
+ if _r23g_has_sink is None or not _r23g_has_sink(cmd):
1954
+ return
1930
1955
  safe_cmd = _redact_for_log(cmd, max_len=160)
1931
- followup_id = _security_followup_id(f"{reason}:{safe_cmd}")
1956
+ # Deterministic id seeded only by the (redacted) command, so the same
1957
+ # exfiltration dedups across shadow/soft/hard instead of duplicating.
1958
+ followup_id = _security_followup_id(safe_cmd)
1932
1959
  try:
1933
1960
  from db import create_followup, get_followup # type: ignore
1934
1961
 
@@ -1937,7 +1964,7 @@ class HeadlessEnforcer:
1937
1964
  create_followup(
1938
1965
  followup_id,
1939
1966
  description=(
1940
- "SEGURIDAD: credencial expuesta o en riesgo detectada por el guard. "
1967
+ "SEGURIDAD: credencial expuesta a un tercero detectada por el guard. "
1941
1968
  f"Origen: {safe_cmd}. Rotar/revocar la credencial y sustituirla en el gestor seguro."
1942
1969
  ),
1943
1970
  date=time.strftime("%Y-%m-%d"),
@@ -1945,7 +1972,7 @@ class HeadlessEnforcer:
1945
1972
  "Cierre solo con evidencia de revocación efectiva: llamada/API/HTTP 401 para la credencial antigua "
1946
1973
  "o comprobación oficial equivalente, más nueva ubicación segura registrada."
1947
1974
  ),
1948
- reasoning=reason,
1975
+ reasoning="R23g detected secret exfiltration to a third party",
1949
1976
  priority="critical",
1950
1977
  internal=1,
1951
1978
  owner="agent",
@@ -244,8 +244,8 @@ def _is_production_mutation_command(cmd: str) -> bool:
244
244
  r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
245
245
  r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
246
246
  r"\bg(?:sutil|cloud\s+storage)\b.*\b(?:cp|rsync)\b.*\b(?:release|stable|cdn|bucket|buckets)\b",
247
- r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot)\b",
248
- r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/)[^'\"]*['\"]",
247
+ r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot|home/nexodesk)\b",
248
+ r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/|/home/nexodesk)[^'\"]*['\"]",
249
249
  r"\b(?:whmapi1|uapi|cpapi2)\b",
250
250
  r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
251
251
  r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
package/src/hooks/stop.py CHANGED
@@ -18,14 +18,24 @@ from pathlib import Path
18
18
 
19
19
  _DIR = Path(__file__).resolve().parent
20
20
 
21
+ # Specific future-commitment phrases. Bare words like "pendiente" / "después"
22
+ # were removed: they appear constantly in ordinary conversation and, read over a
23
+ # GLOBAL rolling buffer, blocked closes spuriously. Each marker now expresses a
24
+ # real deferral, not an incidental adverb.
21
25
  FUTURE_COMMITMENT_MARKERS = (
22
26
  "lo dejo como seguimiento",
23
- "cuando quieras",
24
- "pendiente",
25
27
  "lo cojo aparte",
26
- "después",
27
- "despues",
28
28
  "bloqueado por auth",
29
+ "queda pendiente de",
30
+ "lo dejo pendiente",
31
+ "lo retomo más tarde",
32
+ "lo retomo mas tarde",
33
+ "lo vemos en otra sesión",
34
+ "lo vemos en otra sesion",
35
+ "te lo dejo para después",
36
+ "te lo dejo para despues",
37
+ "lo dejo para más tarde",
38
+ "lo dejo para mas tarde",
29
39
  )
30
40
  FOLLOWUP_CREATE_MARKERS = ("nexo_followup_create", "mcp__nexo__nexo_followup_create")
31
41
  PARTIAL_TASK_CLOSE_RE = re.compile(
@@ -78,6 +88,42 @@ def _read_recent_lines(path: Path, max_lines: int = 800) -> list[str]:
78
88
  return []
79
89
 
80
90
 
91
+ def _line_session_id(raw_line: str) -> str:
92
+ try:
93
+ payload = json.loads(raw_line)
94
+ except Exception:
95
+ return ""
96
+ if not isinstance(payload, dict):
97
+ return ""
98
+ for key in ("session_id", "sid", "claude_session_id", "sessionId"):
99
+ val = payload.get(key)
100
+ if isinstance(val, str) and val.strip():
101
+ return val.strip()
102
+ return ""
103
+
104
+
105
+ def _scope_to_session(lines: list[str], sid: str) -> list[str]:
106
+ """Keep only buffer lines belonging to ``sid``. ``session_buffer.jsonl`` is a
107
+ GLOBAL rolling log shared by every session/client, so without scoping the
108
+ closeout gate counts *other* sessions' commitments and blocks this close
109
+ spuriously. If the buffer carries no session ids at all we cannot scope and
110
+ fall back to every line (prior behaviour)."""
111
+ if not sid:
112
+ return lines
113
+ tagged = [(raw, _line_session_id(raw)) for raw in lines]
114
+ if not any(s for _, s in tagged):
115
+ return lines
116
+ return [raw for raw, s in tagged if s == sid]
117
+
118
+
119
+ def _current_session_id() -> str:
120
+ for key in ("CLAUDE_SESSION_ID", "NEXO_SID", "NEXO_SESSION_ID"):
121
+ val = os.environ.get(key, "").strip()
122
+ if val:
123
+ return val
124
+ return ""
125
+
126
+
81
127
  def _line_text(line: str) -> str:
82
128
  try:
83
129
  payload = json.loads(line)
@@ -133,8 +179,11 @@ def check_closeout_followups() -> dict:
133
179
  if chunk:
134
180
  lines.extend(chunk)
135
181
  sources.append(str(path))
182
+ sid = _current_session_id()
183
+ lines = _scope_to_session(lines, sid)
136
184
  result = scan_closeout_followup_gaps(lines)
137
185
  result["sources"] = sources
186
+ result["session_scoped"] = bool(sid)
138
187
  return result
139
188
 
140
189