nexo-brain 5.3.11 → 5.3.13
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 +3 -5
- package/package.json +1 -1
- package/src/agent_runner.py +2 -0
- package/src/auto_update.py +116 -4
- package/src/cli.py +8 -2
- package/src/client_preferences.py +26 -7
- package/src/client_sync.py +3 -2
- package/src/doctor/orchestrator.py +10 -1
- package/src/doctor/planes.py +87 -0
- package/src/hook_guardrails.py +147 -11
- package/src/plugins/doctor.py +3 -2
- package/src/plugins/schedule.py +119 -1
- package/src/runtime_power.py +3 -2
- package/src/scripts/check-context.py +7 -1
- package/src/scripts/deep-sleep/extract.py +8 -2
- package/src/scripts/deep-sleep/synthesize.py +8 -1
- package/src/scripts/nexo-catchup.py +8 -2
- package/src/scripts/nexo-cortex-cycle.py +48 -21
- package/src/scripts/nexo-daily-self-audit.py +56 -23
- package/src/scripts/nexo-evolution-run.py +10 -2
- package/src/scripts/nexo-immune.py +8 -1
- package/src/scripts/nexo-learning-validator.py +9 -1
- package/src/scripts/nexo-postmortem-consolidator.py +9 -1
- package/src/scripts/nexo-sleep.py +7 -1
- package/src/scripts/nexo-synthesis.py +8 -1
- package/src/scripts/rehydrate_learnings_from_archive.py +245 -0
- package/src/server.py +2 -0
- package/src/skills/run-nexo-core-fix-cycle/guide.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill.json +58 -0
- package/src/skills/run-release-final-audit/guide.md +5 -3
- package/src/skills/run-release-final-audit/script.py +17 -8
- package/src/skills/run-release-final-audit/skill.json +15 -2
- package/src/skills/run-runtime-doctor/script.py +1 -1
package/src/plugins/doctor.py
CHANGED
|
@@ -11,13 +11,14 @@ if str(SRC_DIR) not in sys.path:
|
|
|
11
11
|
sys.path.insert(0, str(SRC_DIR))
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text") -> str:
|
|
14
|
+
def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text", plane: str = "") -> str:
|
|
15
15
|
"""Unified diagnostic report for boot/runtime/deep health.
|
|
16
16
|
|
|
17
17
|
Args:
|
|
18
18
|
tier: Diagnostic tier — boot, runtime, deep, or all (default: boot)
|
|
19
19
|
fix: Apply deterministic fixes (default: False)
|
|
20
20
|
output: Output format — text or json (default: text)
|
|
21
|
+
plane: Diagnostic plane — runtime_personal, installation_live, or database_real
|
|
21
22
|
"""
|
|
22
23
|
from doctor.orchestrator import run_doctor
|
|
23
24
|
from doctor.formatters import format_report
|
|
@@ -27,7 +28,7 @@ def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text") -
|
|
|
27
28
|
if output not in ("text", "json"):
|
|
28
29
|
return f"Invalid output '{output}'. Use: text, json"
|
|
29
30
|
|
|
30
|
-
report = run_doctor(tier=tier, fix=fix)
|
|
31
|
+
report = run_doctor(tier=tier, fix=fix, plane=plane)
|
|
31
32
|
return format_report(report, fmt=output)
|
|
32
33
|
|
|
33
34
|
|
package/src/plugins/schedule.py
CHANGED
|
@@ -20,6 +20,9 @@ from script_registry import (
|
|
|
20
20
|
get_declared_schedule,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
+
LEGACY_BACKUP_CRON_ID = "backup"
|
|
24
|
+
LEGACY_BACKUP_SUMMARY = "legacy backup file evidence"
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
25
28
|
"""Show cron execution status — what ran, what failed, durations.
|
|
@@ -30,6 +33,8 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
|
30
33
|
"""
|
|
31
34
|
if cron_id:
|
|
32
35
|
runs = cron_runs_recent(hours, cron_id)
|
|
36
|
+
if cron_id == LEGACY_BACKUP_CRON_ID:
|
|
37
|
+
runs = _select_backup_runs(runs, hours)
|
|
33
38
|
if not runs:
|
|
34
39
|
return f"No runs for '{cron_id}' in the last {hours}h."
|
|
35
40
|
schedule_meta = get_personal_script_schedule(cron_id) or {}
|
|
@@ -53,7 +58,7 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
|
53
58
|
return "\n".join(lines)
|
|
54
59
|
|
|
55
60
|
# Summary view — one line per cron
|
|
56
|
-
summary = cron_runs_summary(hours)
|
|
61
|
+
summary = _merge_legacy_summaries(cron_runs_summary(hours), hours)
|
|
57
62
|
if not summary:
|
|
58
63
|
return f"No cron executions recorded in the last {hours}h."
|
|
59
64
|
|
|
@@ -82,6 +87,119 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
|
|
|
82
87
|
return "\n".join(lines)
|
|
83
88
|
|
|
84
89
|
|
|
90
|
+
def _select_backup_runs(db_runs: list[dict], hours: int) -> list[dict]:
|
|
91
|
+
legacy_runs = _legacy_backup_runs(hours)
|
|
92
|
+
if _prefer_legacy_over_db(db_runs, legacy_runs):
|
|
93
|
+
return legacy_runs
|
|
94
|
+
return db_runs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _merge_legacy_summaries(summary_rows: list[dict], hours: int) -> list[dict]:
|
|
98
|
+
rows = [dict(row) for row in (summary_rows or [])]
|
|
99
|
+
legacy_summary = _legacy_backup_summary(hours)
|
|
100
|
+
if not legacy_summary:
|
|
101
|
+
return rows
|
|
102
|
+
by_cron_id = {row["cron_id"]: row for row in rows}
|
|
103
|
+
existing = by_cron_id.get(LEGACY_BACKUP_CRON_ID)
|
|
104
|
+
if _prefer_legacy_summary(existing, legacy_summary):
|
|
105
|
+
by_cron_id[LEGACY_BACKUP_CRON_ID] = legacy_summary
|
|
106
|
+
return sorted(
|
|
107
|
+
by_cron_id.values(),
|
|
108
|
+
key=lambda row: row.get("last_run") or "",
|
|
109
|
+
reverse=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _prefer_legacy_over_db(db_runs: list[dict], legacy_runs: list[dict]) -> bool:
|
|
114
|
+
if not legacy_runs:
|
|
115
|
+
return False
|
|
116
|
+
if not db_runs:
|
|
117
|
+
return True
|
|
118
|
+
latest_db = _parse_db_timestamp(db_runs[0].get("started_at"))
|
|
119
|
+
latest_legacy = _parse_db_timestamp(legacy_runs[0].get("started_at"))
|
|
120
|
+
if latest_legacy is None:
|
|
121
|
+
return False
|
|
122
|
+
if latest_db is None:
|
|
123
|
+
return True
|
|
124
|
+
return latest_legacy > latest_db
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _prefer_legacy_summary(existing: dict | None, legacy: dict) -> bool:
|
|
128
|
+
if not legacy:
|
|
129
|
+
return False
|
|
130
|
+
if not existing:
|
|
131
|
+
return True
|
|
132
|
+
latest_existing = _parse_db_timestamp(existing.get("last_run"))
|
|
133
|
+
latest_legacy = _parse_db_timestamp(legacy.get("last_run"))
|
|
134
|
+
if latest_legacy is None:
|
|
135
|
+
return False
|
|
136
|
+
if latest_existing is None:
|
|
137
|
+
return True
|
|
138
|
+
return latest_legacy > latest_existing
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _legacy_backup_summary(hours: int) -> dict | None:
|
|
142
|
+
runs = _legacy_backup_runs(hours)
|
|
143
|
+
if not runs:
|
|
144
|
+
return None
|
|
145
|
+
return _build_summary_from_runs(LEGACY_BACKUP_CRON_ID, runs)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _legacy_backup_runs(hours: int) -> list[dict]:
|
|
149
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
150
|
+
backup_dir = nexo_home / "backups"
|
|
151
|
+
if not backup_dir.exists():
|
|
152
|
+
return []
|
|
153
|
+
cutoff = _now_utc().timestamp() - (hours * 3600)
|
|
154
|
+
runs: list[dict] = []
|
|
155
|
+
for backup_file in backup_dir.glob("nexo-*.db"):
|
|
156
|
+
try:
|
|
157
|
+
stat = backup_file.stat()
|
|
158
|
+
except OSError:
|
|
159
|
+
continue
|
|
160
|
+
if stat.st_mtime < cutoff:
|
|
161
|
+
continue
|
|
162
|
+
started = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).replace(microsecond=0)
|
|
163
|
+
started_at = started.strftime("%Y-%m-%d %H:%M:%S")
|
|
164
|
+
runs.append(
|
|
165
|
+
{
|
|
166
|
+
"cron_id": LEGACY_BACKUP_CRON_ID,
|
|
167
|
+
"started_at": started_at,
|
|
168
|
+
"ended_at": started_at,
|
|
169
|
+
"exit_code": 0,
|
|
170
|
+
"summary": LEGACY_BACKUP_SUMMARY,
|
|
171
|
+
"error": "",
|
|
172
|
+
"duration_secs": 1.0,
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
runs.sort(key=lambda row: row["started_at"], reverse=True)
|
|
176
|
+
return runs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _build_summary_from_runs(cron_id: str, runs: list[dict]) -> dict:
|
|
180
|
+
completed_runs = [
|
|
181
|
+
row for row in runs if row.get("exit_code") is not None and row.get("ended_at")
|
|
182
|
+
]
|
|
183
|
+
duration_values = [
|
|
184
|
+
float(row["duration_secs"])
|
|
185
|
+
for row in completed_runs
|
|
186
|
+
if row.get("duration_secs") is not None
|
|
187
|
+
]
|
|
188
|
+
return {
|
|
189
|
+
"cron_id": cron_id,
|
|
190
|
+
"total_runs": len(runs),
|
|
191
|
+
"succeeded": sum(1 for row in completed_runs if row.get("exit_code") == 0),
|
|
192
|
+
"completed_runs": len(completed_runs),
|
|
193
|
+
"failed": sum(1 for row in completed_runs if row.get("exit_code") not in (None, 0)),
|
|
194
|
+
"open_runs": len(runs) - len(completed_runs),
|
|
195
|
+
"avg_duration": round(sum(duration_values) / len(duration_values), 1) if duration_values else None,
|
|
196
|
+
"last_run": runs[0].get("started_at"),
|
|
197
|
+
"last_exit_code": runs[0].get("exit_code"),
|
|
198
|
+
"last_ended_at": runs[0].get("ended_at"),
|
|
199
|
+
"last_summary": next((row.get("summary") for row in runs if row.get("summary")), ""),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
85
203
|
def _summary_has_warning(summary: str = "") -> bool:
|
|
86
204
|
lowered = str(summary or "").strip().lower()
|
|
87
205
|
if not lowered:
|
package/src/runtime_power.py
CHANGED
|
@@ -67,8 +67,9 @@ MACOS_FDA_PROBE_PATHS = (
|
|
|
67
67
|
)
|
|
68
68
|
DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
|
|
69
69
|
DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
# Codex defaults mirror the user's primary model — no hardcoded third-party models.
|
|
71
|
+
DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
|
|
72
|
+
DEFAULT_CODEX_REASONING_EFFORT = ""
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
def _schedule_defaults() -> dict:
|
|
@@ -21,6 +21,12 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
21
21
|
sys.path.insert(0, str(NEXO_CODE))
|
|
22
22
|
|
|
23
23
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
24
|
+
try:
|
|
25
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
26
|
+
_USER_MODEL = _resolve_user_model()
|
|
27
|
+
except Exception:
|
|
28
|
+
_USER_MODEL = ""
|
|
29
|
+
|
|
24
30
|
|
|
25
31
|
|
|
26
32
|
class ContextChecker:
|
|
@@ -192,7 +198,7 @@ Rules:
|
|
|
192
198
|
try:
|
|
193
199
|
result = run_automation_prompt(
|
|
194
200
|
prompt,
|
|
195
|
-
model="opus",
|
|
201
|
+
model=_USER_MODEL or "opus",
|
|
196
202
|
timeout=300,
|
|
197
203
|
output_format="text",
|
|
198
204
|
append_system_prompt="Return exactly one valid JSON object.",
|
|
@@ -26,6 +26,12 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
26
26
|
sys.path.insert(0, str(NEXO_CODE))
|
|
27
27
|
|
|
28
28
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
29
|
+
try:
|
|
30
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
31
|
+
_USER_MODEL = _resolve_user_model()
|
|
32
|
+
except Exception:
|
|
33
|
+
_USER_MODEL = ""
|
|
34
|
+
|
|
29
35
|
|
|
30
36
|
# No timeout -- headless automation can take as long as needed
|
|
31
37
|
CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
|
|
@@ -128,7 +134,7 @@ def analyze_session(
|
|
|
128
134
|
|
|
129
135
|
result = run_automation_prompt(
|
|
130
136
|
prompt,
|
|
131
|
-
model="opus",
|
|
137
|
+
model=_USER_MODEL or "opus",
|
|
132
138
|
timeout=CLAUDE_TIMEOUT,
|
|
133
139
|
output_format="text",
|
|
134
140
|
append_system_prompt=JSON_SYSTEM_PROMPT,
|
|
@@ -158,7 +164,7 @@ def analyze_session(
|
|
|
158
164
|
)
|
|
159
165
|
convert_result = run_automation_prompt(
|
|
160
166
|
convert_prompt,
|
|
161
|
-
model="sonnet",
|
|
167
|
+
model=_USER_MODEL or "sonnet",
|
|
162
168
|
timeout=120,
|
|
163
169
|
output_format="text",
|
|
164
170
|
append_system_prompt=JSON_SYSTEM_PROMPT,
|
|
@@ -18,6 +18,13 @@ import hashlib
|
|
|
18
18
|
from datetime import datetime
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
24
|
+
_USER_MODEL = _resolve_user_model()
|
|
25
|
+
except Exception:
|
|
26
|
+
_USER_MODEL = ""
|
|
27
|
+
|
|
21
28
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
22
29
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
|
|
23
30
|
DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
|
|
@@ -233,7 +240,7 @@ def main():
|
|
|
233
240
|
try:
|
|
234
241
|
result = run_automation_prompt(
|
|
235
242
|
prompt,
|
|
236
|
-
model="opus",
|
|
243
|
+
model=_USER_MODEL or "opus",
|
|
237
244
|
timeout=CLAUDE_TIMEOUT,
|
|
238
245
|
output_format="text",
|
|
239
246
|
allowed_tools="Read,Grep,Bash",
|
|
@@ -13,6 +13,13 @@ import sys
|
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
19
|
+
_USER_MODEL = _resolve_user_model()
|
|
20
|
+
except Exception:
|
|
21
|
+
_USER_MODEL = ""
|
|
22
|
+
|
|
16
23
|
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
17
24
|
_DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
|
|
18
25
|
_runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
|
|
@@ -126,7 +133,6 @@ def _heal_personal_schedules() -> dict:
|
|
|
126
133
|
summary = {"created": 0, "repaired": 0, "invalid": 0, "error": ""}
|
|
127
134
|
try:
|
|
128
135
|
from script_registry import reconcile_personal_scripts
|
|
129
|
-
|
|
130
136
|
result = reconcile_personal_scripts(dry_run=False)
|
|
131
137
|
ensured = result.get("ensure_schedules", {})
|
|
132
138
|
summary["created"] = len(ensured.get("created", []))
|
|
@@ -273,7 +279,7 @@ Format:
|
|
|
273
279
|
try:
|
|
274
280
|
result = run_automation_prompt(
|
|
275
281
|
prompt,
|
|
276
|
-
model="opus",
|
|
282
|
+
model=_USER_MODEL or "opus",
|
|
277
283
|
timeout=21600,
|
|
278
284
|
output_format="text",
|
|
279
285
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -18,9 +18,9 @@ What this script does (idempotent and best-effort):
|
|
|
18
18
|
3. Detects degradation signals on the 7-day window. The criteria are
|
|
19
19
|
intentionally conservative to avoid false alarms on small samples:
|
|
20
20
|
a. recommendation_accept_rate < 50% AND total_evaluations >= 10
|
|
21
|
-
b. linked_outcome_success_rate < 50% AND
|
|
21
|
+
b. linked_outcome_success_rate < 50% AND linked_outcomes_resolved >= 5
|
|
22
22
|
c. override_success_rate > recommended_success_rate by >= 20pp
|
|
23
|
-
AND
|
|
23
|
+
AND linked_outcomes_resolved >= 5
|
|
24
24
|
4. Opens (or refreshes) NF-CORTEX-QUALITY-DROP followup with the offending
|
|
25
25
|
metrics when degradation is detected. Idempotent: if a non-PENDING /
|
|
26
26
|
resolved followup of the same id already exists, it is updated in
|
|
@@ -96,6 +96,13 @@ def detect_quality_signals(summary: dict) -> list[dict]:
|
|
|
96
96
|
total = int(summary.get("total_evaluations") or 0)
|
|
97
97
|
accept_rate = float(summary.get("recommendation_accept_rate") or 0.0)
|
|
98
98
|
linked_total = int(summary.get("linked_outcomes_total") or 0)
|
|
99
|
+
linked_met = int(summary.get("linked_outcomes_met") or 0)
|
|
100
|
+
linked_missed = int(summary.get("linked_outcomes_missed") or 0)
|
|
101
|
+
linked_pending = int(summary.get("linked_outcomes_pending") or 0)
|
|
102
|
+
linked_resolved = linked_met + linked_missed
|
|
103
|
+
if linked_resolved <= 0 and linked_total > 0:
|
|
104
|
+
# Older callers may omit the met/missed counters; fall back to total minus pending.
|
|
105
|
+
linked_resolved = max(0, linked_total - linked_pending)
|
|
99
106
|
linked_success = float(summary.get("linked_outcome_success_rate") or 0.0)
|
|
100
107
|
recommended_success = float(summary.get("recommended_success_rate") or 0.0)
|
|
101
108
|
override_success = float(summary.get("override_success_rate") or 0.0)
|
|
@@ -114,21 +121,25 @@ def detect_quality_signals(summary: dict) -> list[dict]:
|
|
|
114
121
|
),
|
|
115
122
|
})
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
linked_scope = f"{linked_resolved} resolved linked outcomes"
|
|
125
|
+
if linked_pending > 0:
|
|
126
|
+
linked_scope += f" ({linked_total} total, {linked_pending} pending)"
|
|
127
|
+
|
|
128
|
+
if linked_resolved >= LINKED_MIN_SAMPLE and linked_success < LINKED_SUCCESS_FLOOR:
|
|
118
129
|
signals.append({
|
|
119
130
|
"kind": "linked_success",
|
|
120
131
|
"severity": "warn",
|
|
121
132
|
"metric_value": linked_success,
|
|
122
133
|
"threshold": LINKED_SUCCESS_FLOOR,
|
|
123
|
-
"sample_size":
|
|
134
|
+
"sample_size": linked_resolved,
|
|
124
135
|
"message": (
|
|
125
136
|
f"Cortex linked-outcome success rate {linked_success:.1f}% on "
|
|
126
|
-
f"{
|
|
137
|
+
f"{linked_scope} is below the "
|
|
127
138
|
f"{LINKED_SUCCESS_FLOOR:.0f}% floor."
|
|
128
139
|
),
|
|
129
140
|
})
|
|
130
141
|
|
|
131
|
-
if
|
|
142
|
+
if linked_resolved >= LINKED_MIN_SAMPLE:
|
|
132
143
|
gap = override_success - recommended_success
|
|
133
144
|
if gap >= OVERRIDE_GAP_THRESHOLD:
|
|
134
145
|
signals.append({
|
|
@@ -136,12 +147,12 @@ def detect_quality_signals(summary: dict) -> list[dict]:
|
|
|
136
147
|
"severity": "error",
|
|
137
148
|
"metric_value": gap,
|
|
138
149
|
"threshold": OVERRIDE_GAP_THRESHOLD,
|
|
139
|
-
"sample_size":
|
|
150
|
+
"sample_size": linked_resolved,
|
|
140
151
|
"message": (
|
|
141
152
|
f"Cortex overrides outperform recommendations by {gap:.1f}pp "
|
|
142
153
|
f"(override {override_success:.1f}% vs recommended "
|
|
143
|
-
f"{recommended_success:.1f}% on {
|
|
144
|
-
"
|
|
154
|
+
f"{recommended_success:.1f}% on {linked_scope}). The "
|
|
155
|
+
"recommender is mis-ranking choices."
|
|
145
156
|
),
|
|
146
157
|
})
|
|
147
158
|
|
|
@@ -171,15 +182,37 @@ def _upsert_quality_followup(signals: list[dict]) -> str:
|
|
|
171
182
|
resolved, a fresh row is inserted with the same id (REPLACE) so the
|
|
172
183
|
new degradation pattern is visible.
|
|
173
184
|
"""
|
|
174
|
-
if not signals:
|
|
175
|
-
return "no_signal"
|
|
176
|
-
|
|
177
185
|
try:
|
|
178
|
-
from db import get_followup, get_db
|
|
186
|
+
from db import complete_followup, get_followup, get_db
|
|
179
187
|
except Exception as e:
|
|
180
188
|
_log(f"WARN: cannot import db helpers: {e}")
|
|
181
189
|
return "skipped_no_db"
|
|
182
190
|
|
|
191
|
+
try:
|
|
192
|
+
existing = get_followup(FOLLOWUP_ID)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
_log(f"WARN: get_followup raised: {e}")
|
|
195
|
+
existing = None
|
|
196
|
+
|
|
197
|
+
if not signals:
|
|
198
|
+
if not existing:
|
|
199
|
+
return "no_signal"
|
|
200
|
+
status = str(existing.get("status") or "").upper()
|
|
201
|
+
if status.startswith("COMPLETED") or status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING", "CANCELLED"}:
|
|
202
|
+
return "no_signal"
|
|
203
|
+
try:
|
|
204
|
+
complete_followup(
|
|
205
|
+
FOLLOWUP_ID,
|
|
206
|
+
result=(
|
|
207
|
+
"Auto-resolved by cortex-cycle: no active degradation signals in the "
|
|
208
|
+
"current 7d window."
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
_log(f"WARN: failed to close followup: {e}")
|
|
213
|
+
return "failed_close"
|
|
214
|
+
return "closed"
|
|
215
|
+
|
|
183
216
|
summary_lines = ["Cortex continuous validation found quality degradation:"]
|
|
184
217
|
for sig in signals:
|
|
185
218
|
summary_lines.append(
|
|
@@ -197,12 +230,6 @@ def _upsert_quality_followup(signals: list[dict]) -> str:
|
|
|
197
230
|
)
|
|
198
231
|
now_epoch = datetime.now().timestamp()
|
|
199
232
|
|
|
200
|
-
try:
|
|
201
|
-
existing = get_followup(FOLLOWUP_ID)
|
|
202
|
-
except Exception as e:
|
|
203
|
-
_log(f"WARN: get_followup raised: {e}")
|
|
204
|
-
existing = None
|
|
205
|
-
|
|
206
233
|
try:
|
|
207
234
|
conn = get_db()
|
|
208
235
|
conn.execute(
|
|
@@ -255,8 +282,8 @@ def run() -> int:
|
|
|
255
282
|
f"signals={len(signals)}"
|
|
256
283
|
)
|
|
257
284
|
|
|
258
|
-
|
|
259
|
-
|
|
285
|
+
action = _upsert_quality_followup(signals)
|
|
286
|
+
if signals or action not in {"no_signal"}:
|
|
260
287
|
_log(f"Cortex cycle: followup {FOLLOWUP_ID} {action} ({len(signals)} signal(s))")
|
|
261
288
|
|
|
262
289
|
return 0
|
|
@@ -40,6 +40,12 @@ from agent_runner import AutomationBackendUnavailableError, run_automation_promp
|
|
|
40
40
|
import db as nexo_db
|
|
41
41
|
from public_evolution_queue import queue_public_port_candidate
|
|
42
42
|
|
|
43
|
+
try:
|
|
44
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
45
|
+
_USER_MODEL = _resolve_user_model()
|
|
46
|
+
except Exception:
|
|
47
|
+
_USER_MODEL = ""
|
|
48
|
+
|
|
43
49
|
LOG_DIR = NEXO_HOME / "logs"
|
|
44
50
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
51
|
AUDIT_HISTORY_DIR = LOG_DIR / "self-audit"
|
|
@@ -479,6 +485,43 @@ def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_
|
|
|
479
485
|
|
|
480
486
|
columns = _table_columns(conn, "workflow_goals")
|
|
481
487
|
signature = _topic_signature(sample_goal)
|
|
488
|
+
goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
|
|
489
|
+
|
|
490
|
+
def _write_goal(existing_row: sqlite3.Row, *, reactivated: bool) -> dict:
|
|
491
|
+
updates: dict[str, object] = {}
|
|
492
|
+
if "title" in columns:
|
|
493
|
+
updates["title"] = sample_goal[:140]
|
|
494
|
+
if "objective" in columns:
|
|
495
|
+
updates["objective"] = objective
|
|
496
|
+
if "priority" in columns:
|
|
497
|
+
updates["priority"] = "high"
|
|
498
|
+
if "owner" in columns:
|
|
499
|
+
updates["owner"] = AUDIT_GOAL_OWNER
|
|
500
|
+
if "next_action" in columns:
|
|
501
|
+
updates["next_action"] = next_action
|
|
502
|
+
if "success_signal" in columns:
|
|
503
|
+
updates["success_signal"] = success_signal
|
|
504
|
+
if "shared_state" in columns:
|
|
505
|
+
updates["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
|
|
506
|
+
if reactivated and "status" in columns:
|
|
507
|
+
updates["status"] = "active"
|
|
508
|
+
if reactivated and "blocker_reason" in columns:
|
|
509
|
+
updates["blocker_reason"] = ""
|
|
510
|
+
if reactivated and "closed_at" in columns:
|
|
511
|
+
updates["closed_at"] = None
|
|
512
|
+
if "updated_at" in columns:
|
|
513
|
+
updates["updated_at"] = now_iso
|
|
514
|
+
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
515
|
+
conn.execute(
|
|
516
|
+
f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
|
|
517
|
+
[updates[column] for column in updates] + [existing_row["goal_id"]],
|
|
518
|
+
)
|
|
519
|
+
return {
|
|
520
|
+
"ok": True,
|
|
521
|
+
"action": "reactivated" if reactivated else "updated",
|
|
522
|
+
"goal_id": str(existing_row["goal_id"]),
|
|
523
|
+
}
|
|
524
|
+
|
|
482
525
|
rows = conn.execute(
|
|
483
526
|
"""SELECT * FROM workflow_goals
|
|
484
527
|
WHERE status NOT IN ('completed', 'cancelled', 'abandoned')
|
|
@@ -499,31 +542,21 @@ def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_
|
|
|
499
542
|
next_action = AUDIT_GOAL_NEXT_ACTION
|
|
500
543
|
success_signal = "The theme stops resurfacing in unresolved protocol tasks."
|
|
501
544
|
now_iso = datetime.now().isoformat(timespec="seconds")
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
updates["owner"] = AUDIT_GOAL_OWNER
|
|
512
|
-
if "next_action" in columns:
|
|
513
|
-
updates["next_action"] = next_action
|
|
514
|
-
if "success_signal" in columns:
|
|
515
|
-
updates["success_signal"] = success_signal
|
|
516
|
-
if "updated_at" in columns:
|
|
517
|
-
updates["updated_at"] = now_iso
|
|
518
|
-
assignments = ", ".join(f"{column} = ?" for column in updates)
|
|
519
|
-
conn.execute(
|
|
520
|
-
f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
|
|
521
|
-
[updates[column] for column in updates] + [existing["goal_id"]],
|
|
545
|
+
exact = conn.execute(
|
|
546
|
+
"SELECT * FROM workflow_goals WHERE goal_id = ? LIMIT 1",
|
|
547
|
+
(goal_id,),
|
|
548
|
+
).fetchone()
|
|
549
|
+
if exact is not None:
|
|
550
|
+
exact_status = str(exact["status"] or "").lower()
|
|
551
|
+
return _write_goal(
|
|
552
|
+
exact,
|
|
553
|
+
reactivated=exact_status in {"completed", "cancelled", "abandoned"},
|
|
522
554
|
)
|
|
523
|
-
|
|
555
|
+
|
|
556
|
+
if existing:
|
|
557
|
+
return _write_goal(existing, reactivated=False)
|
|
524
558
|
|
|
525
559
|
# Content fingerprint, not security-sensitive.
|
|
526
|
-
goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8'), usedforsecurity=False).hexdigest()[:8].upper()}"
|
|
527
560
|
values: dict[str, object] = {"goal_id": goal_id}
|
|
528
561
|
if "session_id" in columns:
|
|
529
562
|
values["session_id"] = ""
|
|
@@ -2016,7 +2049,7 @@ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
|
|
|
2016
2049
|
try:
|
|
2017
2050
|
result = run_automation_prompt(
|
|
2018
2051
|
prompt,
|
|
2019
|
-
model="opus",
|
|
2052
|
+
model=_USER_MODEL or "opus",
|
|
2020
2053
|
timeout=21600,
|
|
2021
2054
|
output_format="text",
|
|
2022
2055
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -20,6 +20,14 @@ import sys
|
|
|
20
20
|
from datetime import datetime, date, timedelta
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
26
|
+
_USER_MODEL = _resolve_user_model()
|
|
27
|
+
except Exception:
|
|
28
|
+
_USER_MODEL = ""
|
|
29
|
+
|
|
30
|
+
|
|
23
31
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
24
32
|
# Auto-detect: if running from repo (src/scripts/), use src/ as NEXO_CODE
|
|
25
33
|
_script_dir = Path(__file__).resolve().parent
|
|
@@ -218,7 +226,7 @@ def call_claude_cli(prompt: str) -> str:
|
|
|
218
226
|
"""Call the configured automation backend for the managed evolution prompt."""
|
|
219
227
|
result = run_automation_prompt(
|
|
220
228
|
prompt,
|
|
221
|
-
model="opus",
|
|
229
|
+
model=_USER_MODEL or "opus",
|
|
222
230
|
timeout=CLI_TIMEOUT,
|
|
223
231
|
output_format="text",
|
|
224
232
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -234,7 +242,7 @@ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
|
|
|
234
242
|
prompt,
|
|
235
243
|
cwd=cwd,
|
|
236
244
|
env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
|
|
237
|
-
model="opus",
|
|
245
|
+
model=_USER_MODEL or "opus",
|
|
238
246
|
timeout=CLI_TIMEOUT,
|
|
239
247
|
output_format="text",
|
|
240
248
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
|
|
@@ -23,6 +23,13 @@ import time
|
|
|
23
23
|
from datetime import datetime, date, timedelta
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
29
|
+
_USER_MODEL = _resolve_user_model()
|
|
30
|
+
except Exception:
|
|
31
|
+
_USER_MODEL = ""
|
|
32
|
+
|
|
26
33
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
27
34
|
_script_dir = Path(__file__).resolve().parent
|
|
28
35
|
_repo_src = _script_dir.parent
|
|
@@ -908,7 +915,7 @@ Write the report. Be concise — max 40 lines."""
|
|
|
908
915
|
try:
|
|
909
916
|
result = run_automation_prompt(
|
|
910
917
|
prompt,
|
|
911
|
-
model="opus",
|
|
918
|
+
model=_USER_MODEL or "opus",
|
|
912
919
|
timeout=21600,
|
|
913
920
|
output_format="text",
|
|
914
921
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -37,6 +37,13 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
37
37
|
|
|
38
38
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
39
39
|
|
|
40
|
+
try:
|
|
41
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
42
|
+
_USER_MODEL = _resolve_user_model()
|
|
43
|
+
except Exception:
|
|
44
|
+
_USER_MODEL = ""
|
|
45
|
+
|
|
46
|
+
|
|
40
47
|
|
|
41
48
|
NEXO_DB = NEXO_HOME / "data" / "nexo.db"
|
|
42
49
|
JSON_ONLY_SYSTEM_PROMPT = (
|
|
@@ -151,7 +158,7 @@ Rules:
|
|
|
151
158
|
try:
|
|
152
159
|
result = run_automation_prompt(
|
|
153
160
|
prompt,
|
|
154
|
-
model="sonnet",
|
|
161
|
+
model=_USER_MODEL or "sonnet",
|
|
155
162
|
timeout=60,
|
|
156
163
|
output_format="text",
|
|
157
164
|
append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
|
|
@@ -231,6 +238,7 @@ def _extract_keywords(text: str) -> set:
|
|
|
231
238
|
|
|
232
239
|
def main():
|
|
233
240
|
import argparse
|
|
241
|
+
|
|
234
242
|
parser = argparse.ArgumentParser(description="Validate findings against existing NEXO learnings")
|
|
235
243
|
parser.add_argument("finding", help="The finding text to validate")
|
|
236
244
|
parser.add_argument("--category", "-c", help="Filter learnings by category")
|
|
@@ -38,6 +38,13 @@ sys.path.insert(0, str(NEXO_CODE))
|
|
|
38
38
|
|
|
39
39
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
40
40
|
|
|
41
|
+
try:
|
|
42
|
+
from client_preferences import resolve_user_model as _resolve_user_model
|
|
43
|
+
_USER_MODEL = _resolve_user_model()
|
|
44
|
+
except Exception:
|
|
45
|
+
_USER_MODEL = ""
|
|
46
|
+
|
|
47
|
+
|
|
41
48
|
NEXO_DB = NEXO_HOME / "data" / "nexo.db"
|
|
42
49
|
# Memory directory — adjust to match your project's memory location
|
|
43
50
|
MEMORY_DIR = NEXO_HOME / "memory"
|
|
@@ -212,7 +219,7 @@ Execute without asking."""
|
|
|
212
219
|
try:
|
|
213
220
|
result = run_automation_prompt(
|
|
214
221
|
prompt,
|
|
215
|
-
model="opus",
|
|
222
|
+
model=_USER_MODEL or "opus",
|
|
216
223
|
timeout=21600,
|
|
217
224
|
output_format="text",
|
|
218
225
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -348,6 +355,7 @@ def analyze_force_events():
|
|
|
348
355
|
log(f" {len(today_forces)} --force events")
|
|
349
356
|
|
|
350
357
|
from collections import Counter
|
|
358
|
+
|
|
351
359
|
memory_counts = Counter(r["memory_id"] for r in today_forces)
|
|
352
360
|
for mem_id, count in memory_counts.most_common():
|
|
353
361
|
mem = db.execute(
|