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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +13 -7
- package/src/classifier_local.py +6 -1
- package/src/cli.py +27 -1
- package/src/db/_email_accounts.py +20 -1
- package/src/db/_schema.py +71 -0
- package/src/email_config.py +9 -3
- package/src/hook_guardrails.py +258 -0
- package/src/hooks/session-start.sh +58 -0
- package/src/plugins/protocol.py +41 -0
- package/src/scripts/backfill_task_owner.py +86 -1
- package/src/scripts/deep-sleep/phase_protocol_debt_drain.py +259 -0
- package/src/scripts/nexo_personal_automation.py +85 -0
- package/src/scripts/runner-health-check.py +314 -0
- package/src/server.py +232 -0
package/src/plugins/protocol.py
CHANGED
|
@@ -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
|
+
]
|