nexo-brain 7.1.7 → 7.1.8

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.
@@ -972,6 +972,7 @@ def handle_task_open(
972
972
  stakes: str = "",
973
973
  context_hint: str = "",
974
974
  description: str = "",
975
+ ack_rules: str = "",
975
976
  ) -> str:
976
977
  """Open a protocol task with heartbeat, guard, rules, and Cortex already captured.
977
978
 
@@ -1216,6 +1217,46 @@ def handle_task_open(
1216
1217
  "preventive_followup": preventive_followup,
1217
1218
  "next_action": next_action,
1218
1219
  }
1220
+
1221
+ # G7 (Francisco 2026-04-22): allow inline acknowledgement of blocking
1222
+ # guard rules so the operator does not have to chain a separate
1223
+ # `nexo_task_acknowledge_guard` call. ``ack_rules`` accepts any of:
1224
+ # "#95,#156" "95,156" "95 156" "[95, 156]"
1225
+ # and delegates to ``handle_task_acknowledge_guard`` which already
1226
+ # validates that every blocking rule is covered.
1227
+ if ack_rules and isinstance(ack_rules, str) and ack_rules.strip():
1228
+ raw = ack_rules.replace("#", "").strip()
1229
+ _resolved_task_id = str(response.get("task_id") or "").strip()
1230
+ _has_blocking = bool(
1231
+ (response.get("guard") or {}).get("has_blocking")
1232
+ )
1233
+ if _has_blocking and _resolved_task_id:
1234
+ ack_result_raw = handle_task_acknowledge_guard(
1235
+ sid=sid,
1236
+ task_id=_resolved_task_id,
1237
+ learning_ids=raw,
1238
+ )
1239
+ try:
1240
+ ack_payload = json.loads(ack_result_raw)
1241
+ except Exception:
1242
+ ack_payload = {"ok": False, "error": "ack_guard_parse_failed"}
1243
+ response["ack_guard"] = ack_payload
1244
+ if ack_payload.get("ok"):
1245
+ # Refresh the blocking flag + next_action so the caller
1246
+ # sees the post-ack state instead of the stale pre-ack one.
1247
+ response["guard"] = response.get("guard") or {}
1248
+ response["guard"]["acknowledged_inline"] = True
1249
+ response["next_action"] = (
1250
+ "Blocking guard rules acknowledged inline via task_open."
1251
+ )
1252
+ else:
1253
+ response["ack_guard"] = {
1254
+ "ok": False,
1255
+ "skipped": True,
1256
+ "reason": (
1257
+ "No blocking guard rules on this task — ack_rules had nothing to acknowledge."
1258
+ ),
1259
+ }
1219
1260
  return json.dumps(response, ensure_ascii=False, indent=2)
1220
1261
 
1221
1262
 
@@ -87,6 +87,70 @@ def _load_user_name(calibration_path: Path) -> str:
87
87
  return ""
88
88
 
89
89
 
90
+ _CLASSIFIER_LABELS = (
91
+ "user_decision_required",
92
+ "waiting_for_external_response",
93
+ "agent_automation_cron",
94
+ "other_shared",
95
+ )
96
+ _CLASSIFIER_CONFIDENCE_FLOOR = 0.55
97
+
98
+
99
+ def _load_local_classifier():
100
+ """Lazy import the zero-shot classifier. Returns None if unavailable."""
101
+ try:
102
+ # classifier_local lives next to the ``scripts`` dir at runtime; add
103
+ # the parent so both in-repo (``src/``) and installed
104
+ # (``~/.nexo/core/``) layouts find it.
105
+ here = Path(__file__).resolve().parent
106
+ for candidate in (here.parent, here.parent.parent):
107
+ sys_path = str(candidate)
108
+ if sys_path not in sys.path:
109
+ sys.path.insert(0, sys_path)
110
+ from classifier_local import ( # type: ignore
111
+ LocalZeroShotClassifier,
112
+ is_local_classifier_available_with_install_state,
113
+ )
114
+ except Exception:
115
+ return None
116
+ try:
117
+ if not is_local_classifier_available_with_install_state():
118
+ return None
119
+ return LocalZeroShotClassifier()
120
+ except Exception:
121
+ return None
122
+
123
+
124
+ def _classify_with_local_llm(description: str, classifier) -> str | None:
125
+ """Ask the local zero-shot classifier to pick a semantic owner label.
126
+
127
+ Returns the mapped owner string ('user', 'waiting', 'agent', 'shared')
128
+ or None when the classifier is unavailable, low-confidence, or the text
129
+ is too short to be worth invoking the model. The 40-character floor
130
+ mirrors classifier_local's own noise-discard threshold and keeps the
131
+ migration-time batch cheap.
132
+ """
133
+ if classifier is None:
134
+ return None
135
+ text = (description or "").strip()
136
+ if len(text) < 40:
137
+ return None
138
+ try:
139
+ result = classifier.classify(text, _CLASSIFIER_LABELS)
140
+ except Exception:
141
+ return None
142
+ if result is None:
143
+ return None
144
+ if result.confidence < _CLASSIFIER_CONFIDENCE_FLOOR:
145
+ return None
146
+ return {
147
+ "user_decision_required": "user",
148
+ "waiting_for_external_response": "waiting",
149
+ "agent_automation_cron": "agent",
150
+ "other_shared": "shared",
151
+ }.get(result.label)
152
+
153
+
90
154
  def classify(
91
155
  *,
92
156
  item_id: str,
@@ -94,8 +158,17 @@ def classify(
94
158
  category: str,
95
159
  recurrence: str,
96
160
  user_name: str,
161
+ classifier=None,
97
162
  ) -> str:
98
- """Return one of 'user', 'waiting', 'agent', 'shared'."""
163
+ """Return one of 'user', 'waiting', 'agent', 'shared'.
164
+
165
+ The structural signals (id prefix, category, recurrence) stay rule-based
166
+ because they are unambiguous and cheap. The textual signals (waiting /
167
+ agent / user intent from the description) prefer the local zero-shot
168
+ classifier when available; the Spanish/English keyword regexes stay as
169
+ a graceful fallback so installs without the classifier model still
170
+ migrate correctly.
171
+ """
99
172
  tid = (item_id or "").strip().lower()
100
173
  if tid.startswith("nf-protocol-"):
101
174
  return "user"
@@ -110,6 +183,9 @@ def classify(
110
183
  desc = description or ""
111
184
  desc_low = desc.lower()
112
185
 
186
+ # Operator-name proximity remains a structural signal — if the row
187
+ # explicitly calls out <OperatorName> deciding/reviewing/etc., we trust
188
+ # that without burning an LLM call.
113
189
  if user_name:
114
190
  name_low = user_name.lower()
115
191
  user_verbs = "|".join(re.escape(v) for v in _USER_VERBS_ES + _USER_VERBS_EN)
@@ -120,6 +196,10 @@ def classify(
120
196
  if name_verb_rx.search(desc_low):
121
197
  return "user"
122
198
 
199
+ llm_label = _classify_with_local_llm(desc, classifier)
200
+ if llm_label is not None:
201
+ return llm_label
202
+
123
203
  for rx in _compile(_WAITING_PHRASES):
124
204
  if rx.search(desc_low):
125
205
  return "waiting"
@@ -189,6 +269,10 @@ def run(
189
269
  if not db_path.exists():
190
270
  raise SystemExit(f"nexo.db not found at {db_path}")
191
271
  user_name = _load_user_name(calibration_path)
272
+ # Load the zero-shot classifier once up front so the migration loop does
273
+ # not pay repeated import/init overhead. Returns None on installs without
274
+ # transformers/model — the regex fallback still produces correct owners.
275
+ classifier = _load_local_classifier()
192
276
 
193
277
  conn = sqlite3.connect(str(db_path))
194
278
  try:
@@ -207,6 +291,7 @@ def run(
207
291
  category=row["category"],
208
292
  recurrence=row["recurrence"],
209
293
  user_name=user_name,
294
+ classifier=classifier,
210
295
  )
211
296
  plans.append({"table": table, "id": row["id"], "owner": owner})
212
297
 
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """Deep Sleep phase: auto-drain stale ``protocol_debt`` rows.
3
+
4
+ Block K G2 (Francisco 2026-04-22): the debt table accumulates
5
+ ``unacknowledged_guard_blocking`` + ``missing_cortex_evaluation`` rows
6
+ faster than the operator can resolve them by hand (20 open in 48h,
7
+ 0 auto-resolved). This phase runs nightly and classifies every
8
+ ``resolved_at IS NULL`` row into three buckets:
9
+
10
+ - ``stale``: older than STALE_AGE_DAYS (default 7) *and* the
11
+ referenced ``task_id`` either does not exist any more or is
12
+ closed. Auto-resolved with a ``deep_sleep/stale_auto_drain``
13
+ resolution note so the morning briefing still shows a clean
14
+ audit trail.
15
+ - ``still_valid``: the referenced task is still active. Left
16
+ untouched — the operator will resolve it alongside the task.
17
+ - ``requires_user``: newer than STALE_AGE_DAYS, or referenced task
18
+ is unknown. Emitted as a ``morning_briefing_item`` so the operator
19
+ sees a consolidated list instead of discovering them one by one.
20
+
21
+ Design invariants:
22
+
23
+ - Idempotent: re-running on the same day re-emits the same set of
24
+ auto-resolves without double-writing (``resolved_at IS NULL`` filter
25
+ skips rows already drained).
26
+ - Read-modify-write wrapped in ``BEGIN IMMEDIATE`` so a concurrent
27
+ operator writer cannot race-overwrite ``resolved_at``.
28
+ - Backup-safe: writes an audit JSON to
29
+ ``runtime/operations/deep-sleep/$DATE-protocol-debt-drain.json``
30
+ before (and regardless of) mutation so we can always inspect what
31
+ was drained.
32
+
33
+ Environment:
34
+ NEXO_HOME (optional) — root of the NEXO installation.
35
+ NEXO_DEBT_DRAIN_STALE_DAYS (optional) — override stale cutoff.
36
+ NEXO_DEBT_DRAIN_DRY_RUN=1 — classify + emit JSON but do not write.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import sqlite3
43
+ import sys
44
+ from datetime import datetime, timedelta
45
+ from pathlib import Path
46
+
47
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
48
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
49
+ if str(NEXO_CODE) not in sys.path:
50
+ sys.path.insert(0, str(NEXO_CODE))
51
+
52
+ import paths # noqa: E402 (sys.path tweaked above)
53
+
54
+ DEFAULT_STALE_AGE_DAYS = 7
55
+ AUTO_DRAIN_NOTE = "deep_sleep/stale_auto_drain"
56
+
57
+
58
+ def _stale_cutoff_days() -> int:
59
+ raw = os.environ.get("NEXO_DEBT_DRAIN_STALE_DAYS", "").strip()
60
+ if not raw:
61
+ return DEFAULT_STALE_AGE_DAYS
62
+ try:
63
+ value = int(raw)
64
+ except ValueError:
65
+ return DEFAULT_STALE_AGE_DAYS
66
+ return max(1, value)
67
+
68
+
69
+ def _resolve_db_path() -> Path:
70
+ try:
71
+ return paths.db_path()
72
+ except Exception:
73
+ return NEXO_HOME / "runtime" / "data" / "nexo.db"
74
+
75
+
76
+ def _resolve_ops_dir() -> Path:
77
+ try:
78
+ return paths.operations_dir() / "deep-sleep"
79
+ except Exception:
80
+ return NEXO_HOME / "runtime" / "operations" / "deep-sleep"
81
+
82
+
83
+ def _parse_ts(value: str) -> datetime | None:
84
+ if not value:
85
+ return None
86
+ raw = str(value).strip()
87
+ # Try the three shapes SQLite's ``datetime('now')`` + direct ISO
88
+ # formatters commonly produce. strptime itself rejects trailing noise,
89
+ # so there is no need to pre-truncate the input (earlier revisions did
90
+ # and silently dropped the seconds because ``len('%Y-%m-%d %H:%M:%S')``
91
+ # is smaller than the rendered value).
92
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
93
+ try:
94
+ return datetime.strptime(raw, fmt)
95
+ except Exception:
96
+ continue
97
+ # Some rows drop fractional seconds or timezone — try a lenient fallback
98
+ # before giving up so we do not over-eagerly bucket them as
99
+ # requires_user.
100
+ try:
101
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
102
+ except Exception:
103
+ return None
104
+
105
+
106
+ def _task_is_open(conn: sqlite3.Connection, task_id: str) -> bool | None:
107
+ """Return True if the task is open, False if closed, None if unknown."""
108
+ if not task_id:
109
+ return None
110
+ try:
111
+ row = conn.execute(
112
+ "SELECT status FROM protocol_tasks WHERE task_id = ?",
113
+ (task_id,),
114
+ ).fetchone()
115
+ except sqlite3.OperationalError:
116
+ return None
117
+ if row is None:
118
+ return None
119
+ status = str(row[0] or "").strip().lower()
120
+ # ``open`` is the canonical live state. Everything else (``closed``,
121
+ # ``cancelled``, ``completed``, …) means the task is no longer pinning
122
+ # the debt.
123
+ return status == "open"
124
+
125
+
126
+ def classify_debt(
127
+ *,
128
+ created_at: str,
129
+ task_id: str,
130
+ now: datetime,
131
+ task_open: bool | None,
132
+ stale_age_days: int,
133
+ ) -> str:
134
+ """Return one of ``stale`` / ``still_valid`` / ``requires_user``.
135
+
136
+ Pure function — easy to unit-test without an open DB. Rules:
137
+
138
+ - Task known to be open → ``still_valid`` regardless of age (the
139
+ operator will resolve it together with the task).
140
+ - Task closed OR task_id absent/unknown AND debt older than
141
+ ``stale_age_days`` → ``stale`` (auto-drainable).
142
+ - Anything else → ``requires_user`` (surface in briefing).
143
+ """
144
+ if task_open is True:
145
+ return "still_valid"
146
+ created_dt = _parse_ts(created_at)
147
+ if created_dt is None:
148
+ # Unparseable timestamp: best to surface for the operator rather
149
+ # than silently discard.
150
+ return "requires_user"
151
+ age = now - created_dt
152
+ if age < timedelta(days=stale_age_days):
153
+ return "requires_user"
154
+ # Old enough + task closed/unknown → safe to drain.
155
+ return "stale"
156
+
157
+
158
+ def run(
159
+ *,
160
+ db_path: Path | None = None,
161
+ ops_dir: Path | None = None,
162
+ stale_age_days: int | None = None,
163
+ dry_run: bool | None = None,
164
+ now: datetime | None = None,
165
+ ) -> dict:
166
+ db_path = db_path or _resolve_db_path()
167
+ ops_dir = ops_dir or _resolve_ops_dir()
168
+ stale_age_days = stale_age_days if stale_age_days is not None else _stale_cutoff_days()
169
+ if dry_run is None:
170
+ dry_run = os.environ.get("NEXO_DEBT_DRAIN_DRY_RUN", "").strip() == "1"
171
+ now = now or datetime.utcnow()
172
+
173
+ report: dict = {
174
+ "db_path": str(db_path),
175
+ "stale_age_days": stale_age_days,
176
+ "dry_run": bool(dry_run),
177
+ "ran_at": now.strftime("%Y-%m-%d %H:%M:%S"),
178
+ "totals": {"stale": 0, "still_valid": 0, "requires_user": 0},
179
+ "drained_ids": [],
180
+ "requires_user_summary": [],
181
+ }
182
+
183
+ if not db_path.exists():
184
+ report["error"] = "db_path_missing"
185
+ return report
186
+
187
+ conn = sqlite3.connect(str(db_path))
188
+ try:
189
+ conn.row_factory = sqlite3.Row
190
+ conn.execute("BEGIN IMMEDIATE")
191
+ rows = conn.execute(
192
+ "SELECT id, session_id, task_id, debt_type, severity, evidence, created_at "
193
+ "FROM protocol_debt WHERE resolved_at IS NULL"
194
+ ).fetchall()
195
+ by_type: dict[str, int] = {}
196
+ for row in rows:
197
+ task_open = _task_is_open(conn, str(row["task_id"] or ""))
198
+ bucket = classify_debt(
199
+ created_at=str(row["created_at"] or ""),
200
+ task_id=str(row["task_id"] or ""),
201
+ now=now,
202
+ task_open=task_open,
203
+ stale_age_days=stale_age_days,
204
+ )
205
+ report["totals"][bucket] = report["totals"].get(bucket, 0) + 1
206
+ if bucket == "stale":
207
+ report["drained_ids"].append(int(row["id"]))
208
+ if not dry_run:
209
+ conn.execute(
210
+ "UPDATE protocol_debt SET resolved_at = ?, resolution = ? "
211
+ "WHERE id = ? AND resolved_at IS NULL",
212
+ (
213
+ now.strftime("%Y-%m-%d %H:%M:%S"),
214
+ AUTO_DRAIN_NOTE,
215
+ int(row["id"]),
216
+ ),
217
+ )
218
+ elif bucket == "requires_user":
219
+ by_type[str(row["debt_type"])] = by_type.get(str(row["debt_type"]), 0) + 1
220
+ # Consolidate requires_user into a per-type summary so the morning
221
+ # briefing stays short even when the backlog is long.
222
+ report["requires_user_summary"] = [
223
+ {"debt_type": debt_type, "count": count}
224
+ for debt_type, count in sorted(by_type.items(), key=lambda x: -x[1])
225
+ ]
226
+ if dry_run:
227
+ conn.execute("ROLLBACK")
228
+ else:
229
+ conn.execute("COMMIT")
230
+ except Exception as exc:
231
+ try:
232
+ conn.execute("ROLLBACK")
233
+ except Exception:
234
+ pass
235
+ report["error"] = f"{type(exc).__name__}: {exc}"
236
+ finally:
237
+ conn.close()
238
+
239
+ # Persist the audit JSON even when drained_ids is empty so the daily
240
+ # Deep Sleep surface always has a file to reference.
241
+ try:
242
+ ops_dir.mkdir(parents=True, exist_ok=True)
243
+ audit_path = ops_dir / f"{now.strftime('%Y-%m-%d')}-protocol-debt-drain.json"
244
+ audit_path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
245
+ report["audit_path"] = str(audit_path)
246
+ except Exception as exc:
247
+ report["audit_write_error"] = f"{type(exc).__name__}: {exc}"
248
+
249
+ return report
250
+
251
+
252
+ def main(argv: list[str] | None = None) -> int:
253
+ report = run()
254
+ print(json.dumps(report, indent=2, ensure_ascii=False))
255
+ return 0 if "error" not in report else 1
256
+
257
+
258
+ if __name__ == "__main__":
259
+ sys.exit(main())
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Stable automation helper that routes prompts through the configured
3
+ NEXO backend (agent_runner / run_automation_text) instead of hardcoding
4
+ provider CLIs such as ``claude -p``.
5
+
6
+ Block E.6 / NF-DS-857651BA promoted this module from personal/scripts to
7
+ core so every NEXO install exposes the same primitive to its scripts,
8
+ plugins, and skills. The behaviour is unchanged from the personal copy;
9
+ only the import bootstrap learns both layouts:
10
+
11
+ - repo checkout (``nexo/src/scripts/…``): ``_repo_root`` is
12
+ ``nexo/`` and templates live at ``nexo/templates/``.
13
+ - installed runtime (``~/.nexo/core/scripts/…``): ``_repo_root`` is
14
+ ``~/.nexo/`` and templates live at ``~/.nexo/templates/``.
15
+
16
+ Both paths are probed so dev and live operators get identical behaviour.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ _script_dir = Path(__file__).resolve().parent
26
+ _repo_src = _script_dir.parent # ``src`` in repo, ``core`` in runtime
27
+ _repo_root = _repo_src.parent # ``nexo`` in repo, ``~/.nexo`` in runtime
28
+
29
+ if str(_repo_src) not in sys.path:
30
+ sys.path.insert(0, str(_repo_src))
31
+
32
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
33
+ DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
34
+
35
+ # Templates live next to the code at repo time and at ``~/.nexo/templates``
36
+ # once installed. Probe both and surface whichever exists first so the
37
+ # helper works without the operator having to keep ``NEXO_HOME`` in sync
38
+ # with the repo checkout during development.
39
+ for _candidate in (_repo_root / "templates", NEXO_HOME / "templates"):
40
+ _cand = str(_candidate)
41
+ if _candidate.exists() and _cand not in sys.path:
42
+ sys.path.insert(0, _cand)
43
+
44
+ try:
45
+ from client_preferences import resolve_user_model
46
+ _USER_MODEL = resolve_user_model()
47
+ except Exception:
48
+ _USER_MODEL = ""
49
+
50
+ from nexo_helper import run_automation_text as _run_automation_text
51
+
52
+
53
+ def run_personal_automation_text(
54
+ prompt: str,
55
+ *,
56
+ model: str = "",
57
+ cwd: str = "",
58
+ timeout: int = 21600,
59
+ allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
60
+ append_system_prompt: str = "",
61
+ ) -> str:
62
+ """Run ``prompt`` through the configured NEXO automation backend.
63
+
64
+ ``model`` empty → use whichever model the operator's calibration has
65
+ selected (``resolve_user_model``); providers that ignore the field
66
+ (Claude Code bundled) stay happy with an empty string.
67
+ ``cwd`` empty → inherit the current working directory.
68
+ Every other kwarg passes through verbatim.
69
+ """
70
+ effective_model = model or _USER_MODEL or "opus"
71
+ return _run_automation_text(
72
+ prompt,
73
+ model=effective_model,
74
+ cwd=cwd or "",
75
+ timeout=timeout,
76
+ allowed_tools=allowed_tools,
77
+ append_system_prompt=append_system_prompt,
78
+ )
79
+
80
+
81
+ __all__ = [
82
+ "DEFAULT_ALLOWED_TOOLS",
83
+ "NEXO_HOME",
84
+ "run_personal_automation_text",
85
+ ]