nexo-brain 2.6.21 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -992,6 +992,372 @@ def _load_period_syntheses(target_date: str, *, window_days: int) -> list[dict]:
992
992
  return syntheses
993
993
 
994
994
 
995
+ def _load_period_extractions(target_date: str, *, window_days: int) -> list[dict]:
996
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
997
+ payloads: list[dict] = []
998
+ for offset in range(window_days):
999
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1000
+ path = DEEP_SLEEP_DIR / f"{date_str}-extractions.json"
1001
+ if not path.is_file():
1002
+ continue
1003
+ try:
1004
+ payload = json.loads(path.read_text())
1005
+ except Exception:
1006
+ continue
1007
+ if isinstance(payload, dict):
1008
+ payloads.append(payload)
1009
+ payloads.reverse()
1010
+ return payloads
1011
+
1012
+
1013
+ def _load_period_applied_logs(target_date: str, *, window_days: int) -> list[dict]:
1014
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
1015
+ payloads: list[dict] = []
1016
+ for offset in range(window_days):
1017
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1018
+ path = DEEP_SLEEP_DIR / f"{date_str}-applied.json"
1019
+ if not path.is_file():
1020
+ continue
1021
+ try:
1022
+ payload = json.loads(path.read_text())
1023
+ except Exception:
1024
+ continue
1025
+ if isinstance(payload, dict):
1026
+ payloads.append(payload)
1027
+ payloads.reverse()
1028
+ return payloads
1029
+
1030
+
1031
+ def _safe_pct(numerator: float, denominator: float) -> float | None:
1032
+ if denominator <= 0:
1033
+ return None
1034
+ return round((numerator / denominator) * 100.0, 1)
1035
+
1036
+
1037
+ def _aggregate_protocol_summary(extractions: list[dict]) -> dict:
1038
+ totals = {
1039
+ "sessions": 0,
1040
+ "guard_check": {"required": 0, "executed": 0},
1041
+ "heartbeat": {"total": 0, "with_context": 0},
1042
+ "change_log": {"edits": 0, "logged": 0},
1043
+ }
1044
+
1045
+ for payload in extractions:
1046
+ for item in payload.get("extractions", []) or []:
1047
+ if not isinstance(item, dict) or item.get("error"):
1048
+ continue
1049
+ totals["sessions"] += 1
1050
+ protocol_summary = item.get("protocol_summary") or {}
1051
+ for key in ("guard_check", "heartbeat", "change_log"):
1052
+ current = protocol_summary.get(key) or {}
1053
+ if key == "guard_check":
1054
+ totals[key]["required"] += int(current.get("required", 0) or 0)
1055
+ totals[key]["executed"] += int(current.get("executed", 0) or 0)
1056
+ elif key == "heartbeat":
1057
+ totals[key]["total"] += int(current.get("total", 0) or 0)
1058
+ totals[key]["with_context"] += int(current.get("with_context", 0) or 0)
1059
+ else:
1060
+ totals[key]["edits"] += int(current.get("edits", 0) or 0)
1061
+ totals[key]["logged"] += int(current.get("logged", 0) or 0)
1062
+
1063
+ guard_pct = _safe_pct(totals["guard_check"]["executed"], totals["guard_check"]["required"])
1064
+ heartbeat_pct = _safe_pct(totals["heartbeat"]["with_context"], totals["heartbeat"]["total"])
1065
+ change_pct = _safe_pct(totals["change_log"]["logged"], totals["change_log"]["edits"])
1066
+ available = [value for value in (guard_pct, heartbeat_pct, change_pct) if value is not None]
1067
+
1068
+ totals["guard_check"]["compliance_pct"] = guard_pct
1069
+ totals["heartbeat"]["compliance_pct"] = heartbeat_pct
1070
+ totals["change_log"]["compliance_pct"] = change_pct
1071
+ totals["overall_compliance_pct"] = round(sum(available) / len(available), 1) if available else None
1072
+ return totals
1073
+
1074
+
1075
+ def _aggregate_delivery_metrics(applied_logs: list[dict]) -> dict:
1076
+ totals = {
1077
+ "runs": len(applied_logs),
1078
+ "applied_actions": 0,
1079
+ "deferred_actions": 0,
1080
+ "skipped_dedupe": 0,
1081
+ "errors": 0,
1082
+ "engineering_followups": 0,
1083
+ "followup_dedupe_matches": 0,
1084
+ "learning_reinforcements": 0,
1085
+ "learning_duplicate_skips": 0,
1086
+ "learning_contradiction_reviews": 0,
1087
+ }
1088
+ for payload in applied_logs:
1089
+ stats = payload.get("stats") or {}
1090
+ totals["applied_actions"] += int(stats.get("applied", 0) or 0)
1091
+ totals["deferred_actions"] += int(stats.get("deferred", 0) or 0)
1092
+ totals["skipped_dedupe"] += int(stats.get("skipped_dedupe", 0) or 0)
1093
+ totals["errors"] += int(stats.get("errors", 0) or 0)
1094
+ for action in payload.get("applied_actions", []) or []:
1095
+ details = action.get("details") or {}
1096
+ if action.get("action_type") == "followup_create":
1097
+ description = str(details.get("description", "") or "") + " " + str(details.get("reasoning", "") or "")
1098
+ if "engineering" in description.lower() or "guardrail" in description.lower():
1099
+ totals["engineering_followups"] += 1
1100
+ if details.get("outcome") == "matched_existing_followup":
1101
+ totals["followup_dedupe_matches"] += 1
1102
+ elif action.get("action_type") == "learning_add":
1103
+ outcome = str(details.get("outcome", "") or "")
1104
+ if outcome == "reinforced_learning":
1105
+ totals["learning_reinforcements"] += 1
1106
+ elif outcome == "duplicate_learning":
1107
+ totals["learning_duplicate_skips"] += 1
1108
+ elif outcome == "contradiction_review":
1109
+ totals["learning_contradiction_reviews"] += 1
1110
+
1111
+ attempted = totals["applied_actions"] + totals["deferred_actions"] + totals["skipped_dedupe"] + totals["errors"]
1112
+ totals["dedupe_rate_pct"] = _safe_pct(totals["skipped_dedupe"], attempted)
1113
+ totals["error_rate_pct"] = _safe_pct(totals["errors"], attempted)
1114
+ return totals
1115
+
1116
+
1117
+ def _semantic_duplicate_metrics(items: list[tuple[str, str]], *, threshold: float = 0.82) -> dict:
1118
+ filtered = [(item_id, _normalize_text(text)) for item_id, text in items if _normalize_text(text)]
1119
+ if len(filtered) < 2:
1120
+ return {
1121
+ "cluster_count": 0,
1122
+ "duplicate_items": 0,
1123
+ "duplicate_excess": 0,
1124
+ "sample_clusters": [],
1125
+ }
1126
+
1127
+ used: set[int] = set()
1128
+ clusters: list[list[tuple[str, str]]] = []
1129
+ for index, (item_id, text) in enumerate(filtered):
1130
+ if index in used:
1131
+ continue
1132
+ cluster = [(item_id, text)]
1133
+ for other_index in range(index + 1, len(filtered)):
1134
+ if other_index in used:
1135
+ continue
1136
+ other_id, other_text = filtered[other_index]
1137
+ if _text_similarity(text, other_text) >= threshold:
1138
+ cluster.append((other_id, other_text))
1139
+ used.add(other_index)
1140
+ if len(cluster) > 1:
1141
+ used.add(index)
1142
+ clusters.append(cluster)
1143
+
1144
+ return {
1145
+ "cluster_count": len(clusters),
1146
+ "duplicate_items": sum(len(cluster) for cluster in clusters),
1147
+ "duplicate_excess": sum(max(0, len(cluster) - 1) for cluster in clusters),
1148
+ "sample_clusters": [
1149
+ [item_id for item_id, _ in cluster[:4]]
1150
+ for cluster in clusters[:5]
1151
+ ],
1152
+ }
1153
+
1154
+
1155
+ def _followup_deduplication_metrics() -> dict:
1156
+ cols = _table_columns(NEXO_DB, "followups")
1157
+ if "description" not in cols:
1158
+ return {
1159
+ "open_followups": 0,
1160
+ "duplicate_clusters": 0,
1161
+ "duplicate_open_followups": 0,
1162
+ "duplicate_rate_pct": None,
1163
+ "sample_clusters": [],
1164
+ }
1165
+
1166
+ select_cols = ["description"]
1167
+ if "id" in cols:
1168
+ select_cols.append("id")
1169
+ if "status" in cols:
1170
+ select_cols.append("status")
1171
+
1172
+ conn = sqlite3.connect(str(NEXO_DB))
1173
+ conn.row_factory = sqlite3.Row
1174
+ rows = [dict(row) for row in conn.execute(f"SELECT {', '.join(select_cols)} FROM followups").fetchall()]
1175
+ conn.close()
1176
+
1177
+ open_rows = []
1178
+ for row in rows:
1179
+ status = str(row.get("status", "pending") or "pending").strip().lower()
1180
+ if status in {"done", "completed", "cancelled", "resolved"}:
1181
+ continue
1182
+ identifier = str(row.get("id") or row.get("description") or f"followup-{len(open_rows)+1}")
1183
+ open_rows.append((identifier, str(row.get("description", "") or "")))
1184
+
1185
+ duplicates = _semantic_duplicate_metrics(open_rows)
1186
+ return {
1187
+ "open_followups": len(open_rows),
1188
+ "duplicate_clusters": duplicates["cluster_count"],
1189
+ "duplicate_open_followups": duplicates["duplicate_excess"],
1190
+ "duplicate_rate_pct": _safe_pct(duplicates["duplicate_excess"], len(open_rows)),
1191
+ "sample_clusters": duplicates["sample_clusters"],
1192
+ }
1193
+
1194
+
1195
+ def _learning_consolidation_metrics() -> dict:
1196
+ cols = _table_columns(NEXO_DB, "learnings")
1197
+ if not {"title", "content"}.issubset(cols):
1198
+ return {
1199
+ "active_learnings": 0,
1200
+ "weak_active_learnings": 0,
1201
+ "duplicate_clusters": 0,
1202
+ "duplicate_active_learnings": 0,
1203
+ "noise_pressure": 0,
1204
+ "noise_rate_pct": None,
1205
+ "sample_clusters": [],
1206
+ }
1207
+
1208
+ select_cols = ["title", "content"]
1209
+ if "id" in cols:
1210
+ select_cols.append("id")
1211
+ for field in ("status", "weight", "reasoning", "prevention", "applies_to", "guard_hits"):
1212
+ if field in cols:
1213
+ select_cols.append(field)
1214
+
1215
+ conn = sqlite3.connect(str(NEXO_DB))
1216
+ conn.row_factory = sqlite3.Row
1217
+ rows = [dict(row) for row in conn.execute(f"SELECT {', '.join(select_cols)} FROM learnings").fetchall()]
1218
+ conn.close()
1219
+
1220
+ active_rows = []
1221
+ weak_active = 0
1222
+ for row in rows:
1223
+ status = str(row.get("status", "active") or "active").strip().lower()
1224
+ if status != "active":
1225
+ continue
1226
+ active_rows.append(row)
1227
+ weight = row.get("weight")
1228
+ reasoning = str(row.get("reasoning", "") or "").strip()
1229
+ prevention = str(row.get("prevention", "") or "").strip()
1230
+ guard_hits = int(row.get("guard_hits", 0) or 0)
1231
+ applies_to = str(row.get("applies_to", "") or "").strip()
1232
+ if isinstance(weight, (int, float)) and float(weight) < 1.0:
1233
+ weak_active += 1
1234
+ elif not reasoning and not prevention:
1235
+ weak_active += 1
1236
+ elif applies_to and guard_hits <= 0:
1237
+ weak_active += 1
1238
+
1239
+ duplicates = _semantic_duplicate_metrics(
1240
+ [
1241
+ (str(row.get("id") or f"learning-{index}"), f"{row.get('title', '')} {row.get('content', '')}")
1242
+ for index, row in enumerate(active_rows, 1)
1243
+ ],
1244
+ threshold=0.8,
1245
+ )
1246
+ noise_pressure = weak_active + duplicates["duplicate_excess"]
1247
+ return {
1248
+ "active_learnings": len(active_rows),
1249
+ "weak_active_learnings": weak_active,
1250
+ "duplicate_clusters": duplicates["cluster_count"],
1251
+ "duplicate_active_learnings": duplicates["duplicate_excess"],
1252
+ "noise_pressure": noise_pressure,
1253
+ "noise_rate_pct": _safe_pct(noise_pressure, len(active_rows)),
1254
+ "sample_clusters": duplicates["sample_clusters"],
1255
+ }
1256
+
1257
+
1258
+ def _load_previous_period_summary(kind: str, label: str) -> dict | None:
1259
+ pattern = f"*-{kind}-summary.json"
1260
+ candidates: list[tuple[str, Path]] = []
1261
+ for path in DEEP_SLEEP_DIR.glob(pattern):
1262
+ try:
1263
+ payload = json.loads(path.read_text())
1264
+ except Exception:
1265
+ continue
1266
+ candidate_label = str(payload.get("label", "") or "")
1267
+ if candidate_label and candidate_label < label:
1268
+ candidates.append((candidate_label, path))
1269
+ if not candidates:
1270
+ return None
1271
+ _, path = sorted(candidates, key=lambda item: item[0])[-1]
1272
+ try:
1273
+ payload = json.loads(path.read_text())
1274
+ except Exception:
1275
+ return None
1276
+ return payload if isinstance(payload, dict) else None
1277
+
1278
+
1279
+ def _build_project_pulse(top_projects: list[dict], previous_summary: dict | None) -> list[dict]:
1280
+ previous_scores: dict[str, float] = {}
1281
+ if previous_summary:
1282
+ for item in previous_summary.get("project_pulse", []) or previous_summary.get("top_projects", []) or []:
1283
+ project = str(item.get("project", "") or "")
1284
+ if project:
1285
+ previous_scores[project] = float(item.get("score", 0) or 0)
1286
+
1287
+ pulse: list[dict] = []
1288
+ for item in top_projects:
1289
+ project = str(item.get("project", "") or "")
1290
+ score = float(item.get("score", 0) or 0)
1291
+ previous_score = previous_scores.get(project, 0.0)
1292
+ delta = round(score - previous_score, 2)
1293
+ if score >= 18:
1294
+ status = "critical"
1295
+ elif score >= 10:
1296
+ status = "elevated"
1297
+ else:
1298
+ status = "watch"
1299
+ if delta >= 2.0:
1300
+ trend = "rising"
1301
+ elif delta <= -2.0:
1302
+ trend = "cooling"
1303
+ else:
1304
+ trend = "steady"
1305
+ pulse.append(
1306
+ {
1307
+ "project": project,
1308
+ "score": round(score, 2),
1309
+ "delta_vs_previous": delta,
1310
+ "trend": trend,
1311
+ "status": status,
1312
+ "signals": item.get("signals", {}),
1313
+ "reasons": item.get("reasons", []),
1314
+ }
1315
+ )
1316
+ return pulse
1317
+
1318
+
1319
+ def _build_period_trend(summary: dict, previous_summary: dict | None) -> dict:
1320
+ if not previous_summary:
1321
+ return {
1322
+ "has_previous": False,
1323
+ "avg_mood_delta": None,
1324
+ "avg_trust_delta": None,
1325
+ "total_corrections_delta": None,
1326
+ "protocol_compliance_delta": None,
1327
+ "followup_duplicate_open_delta": None,
1328
+ "followup_duplicate_rate_delta": None,
1329
+ "learning_noise_delta": None,
1330
+ "learning_noise_rate_delta": None,
1331
+ }
1332
+
1333
+ current_protocol = summary.get("protocol_summary", {}).get("overall_compliance_pct")
1334
+ previous_protocol = (previous_summary.get("protocol_summary") or {}).get("overall_compliance_pct")
1335
+ current_mood = summary.get("avg_mood_score")
1336
+ previous_mood = previous_summary.get("avg_mood_score")
1337
+ current_trust = summary.get("avg_trust_score")
1338
+ previous_trust = previous_summary.get("avg_trust_score")
1339
+ current_followup = (summary.get("followup_deduplication") or {}).get("duplicate_open_followups")
1340
+ previous_followup = (previous_summary.get("followup_deduplication") or {}).get("duplicate_open_followups")
1341
+ current_followup_rate = (summary.get("followup_deduplication") or {}).get("duplicate_rate_pct")
1342
+ previous_followup_rate = (previous_summary.get("followup_deduplication") or {}).get("duplicate_rate_pct")
1343
+ current_learning_noise = (summary.get("learning_consolidation") or {}).get("noise_pressure")
1344
+ previous_learning_noise = (previous_summary.get("learning_consolidation") or {}).get("noise_pressure")
1345
+ current_learning_rate = (summary.get("learning_consolidation") or {}).get("noise_rate_pct")
1346
+ previous_learning_rate = (previous_summary.get("learning_consolidation") or {}).get("noise_rate_pct")
1347
+
1348
+ return {
1349
+ "has_previous": True,
1350
+ "avg_mood_delta": round(current_mood - previous_mood, 3) if isinstance(current_mood, (int, float)) and isinstance(previous_mood, (int, float)) else None,
1351
+ "avg_trust_delta": round(current_trust - previous_trust, 1) if isinstance(current_trust, (int, float)) and isinstance(previous_trust, (int, float)) else None,
1352
+ "total_corrections_delta": int(summary.get("total_corrections", 0) or 0) - int(previous_summary.get("total_corrections", 0) or 0),
1353
+ "protocol_compliance_delta": round(current_protocol - previous_protocol, 1) if isinstance(current_protocol, (int, float)) and isinstance(previous_protocol, (int, float)) else None,
1354
+ "followup_duplicate_open_delta": int(current_followup or 0) - int(previous_followup or 0) if current_followup is not None or previous_followup is not None else None,
1355
+ "followup_duplicate_rate_delta": round(float(current_followup_rate) - float(previous_followup_rate), 1) if isinstance(current_followup_rate, (int, float)) and isinstance(previous_followup_rate, (int, float)) else None,
1356
+ "learning_noise_delta": int(current_learning_noise or 0) - int(previous_learning_noise or 0) if current_learning_noise is not None or previous_learning_noise is not None else None,
1357
+ "learning_noise_rate_delta": round(float(current_learning_rate) - float(previous_learning_rate), 1) if isinstance(current_learning_rate, (int, float)) and isinstance(previous_learning_rate, (int, float)) else None,
1358
+ }
1359
+
1360
+
995
1361
  def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, window_days: int) -> dict:
996
1362
  target_day = datetime.strptime(target_date, "%Y-%m-%d")
997
1363
  window_start = (target_day - timedelta(days=max(0, window_days - 1))).strftime("%Y-%m-%d")
@@ -1001,6 +1367,8 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1001
1367
  else target_day.strftime("%Y-%m")
1002
1368
  )
1003
1369
  syntheses = _load_period_syntheses(target_date, window_days=window_days)
1370
+ extractions = _load_period_extractions(target_date, window_days=window_days)
1371
+ applied_logs = _load_period_applied_logs(target_date, window_days=window_days)
1004
1372
  if not any(item.get("date") == target_date for item in syntheses):
1005
1373
  syntheses.append(synthesis)
1006
1374
 
@@ -1037,6 +1405,12 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1037
1405
  {"title": title, "count": count}
1038
1406
  for title, count in agenda_counter.most_common(6)
1039
1407
  ]
1408
+ protocol_summary = _aggregate_protocol_summary(extractions)
1409
+ delivery_metrics = _aggregate_delivery_metrics(applied_logs)
1410
+ followup_deduplication = _followup_deduplication_metrics()
1411
+ learning_consolidation = _learning_consolidation_metrics()
1412
+ previous_summary = _load_previous_period_summary(kind, label)
1413
+ project_pulse = _build_project_pulse(top_projects, previous_summary)
1040
1414
 
1041
1415
  summary_parts = [f"{len(syntheses)} Deep Sleep run(s)"]
1042
1416
  if top_projects:
@@ -1045,9 +1419,11 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1045
1419
  summary_parts.append(f"recurring pattern: {top_patterns[0]['pattern']}")
1046
1420
  if avg_trust is not None:
1047
1421
  summary_parts.append(f"avg trust {avg_trust:.1f}")
1422
+ if protocol_summary.get("overall_compliance_pct") is not None:
1423
+ summary_parts.append(f"protocol {protocol_summary['overall_compliance_pct']:.1f}%")
1048
1424
  summary = " | ".join(summary_parts)
1049
1425
 
1050
- return {
1426
+ period_summary = {
1051
1427
  "kind": kind,
1052
1428
  "label": label,
1053
1429
  "window_days": window_days,
@@ -1059,11 +1435,17 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1059
1435
  "avg_trust_score": avg_trust,
1060
1436
  "total_corrections": total_corrections,
1061
1437
  "top_projects": top_projects,
1438
+ "project_pulse": project_pulse,
1062
1439
  "top_patterns": top_patterns,
1063
1440
  "recurring_agenda": recurring_agenda,
1441
+ "protocol_summary": protocol_summary,
1442
+ "delivery_metrics": delivery_metrics,
1443
+ "followup_deduplication": followup_deduplication,
1444
+ "learning_consolidation": learning_consolidation,
1064
1445
  "summary": summary,
1065
1446
  }
1066
-
1447
+ period_summary["trend"] = _build_period_trend(period_summary, previous_summary)
1448
+ return period_summary
1067
1449
 
1068
1450
  def _render_period_summary_markdown(summary: dict) -> str:
1069
1451
  lines = [
@@ -1082,6 +1464,68 @@ def _render_period_summary_markdown(summary: dict) -> str:
1082
1464
  lines.append(f"> {summary['summary']}")
1083
1465
  lines.append("")
1084
1466
 
1467
+ protocol_summary = summary.get("protocol_summary") or {}
1468
+ if protocol_summary:
1469
+ lines.append("## Protocol Compliance")
1470
+ lines.append("")
1471
+ overall = protocol_summary.get("overall_compliance_pct")
1472
+ if overall is not None:
1473
+ lines.append(f"- Overall compliance: {overall:.1f}%")
1474
+ guard = protocol_summary.get("guard_check", {})
1475
+ heartbeat = protocol_summary.get("heartbeat", {})
1476
+ change_log = protocol_summary.get("change_log", {})
1477
+ if guard:
1478
+ lines.append(
1479
+ f"- guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)}"
1480
+ + (f" ({guard['compliance_pct']:.1f}%)" if guard.get("compliance_pct") is not None else "")
1481
+ )
1482
+ if heartbeat:
1483
+ lines.append(
1484
+ f"- heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)}"
1485
+ + (f" ({heartbeat['compliance_pct']:.1f}%)" if heartbeat.get("compliance_pct") is not None else "")
1486
+ )
1487
+ if change_log:
1488
+ lines.append(
1489
+ f"- change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)}"
1490
+ + (f" ({change_log['compliance_pct']:.1f}%)" if change_log.get("compliance_pct") is not None else "")
1491
+ )
1492
+ lines.append("")
1493
+
1494
+ delivery_metrics = summary.get("delivery_metrics") or {}
1495
+ if delivery_metrics:
1496
+ lines.append("## Loop Output")
1497
+ lines.append("")
1498
+ lines.append(f"- Applied actions: {delivery_metrics.get('applied_actions', 0)}")
1499
+ lines.append(f"- Deferred actions: {delivery_metrics.get('deferred_actions', 0)}")
1500
+ lines.append(f"- Dedupe skips: {delivery_metrics.get('skipped_dedupe', 0)}")
1501
+ lines.append(f"- Engineering followups: {delivery_metrics.get('engineering_followups', 0)}")
1502
+ if delivery_metrics.get("dedupe_rate_pct") is not None:
1503
+ lines.append(f"- Dedupe rate: {delivery_metrics['dedupe_rate_pct']:.1f}%")
1504
+ if delivery_metrics.get("error_rate_pct") is not None:
1505
+ lines.append(f"- Error rate: {delivery_metrics['error_rate_pct']:.1f}%")
1506
+ lines.append(f"- Followup dedupe matches: {delivery_metrics.get('followup_dedupe_matches', 0)}")
1507
+ lines.append(f"- Learning reinforcements: {delivery_metrics.get('learning_reinforcements', 0)}")
1508
+ lines.append(f"- Learning duplicate skips: {delivery_metrics.get('learning_duplicate_skips', 0)}")
1509
+ lines.append(f"- Learning contradiction reviews: {delivery_metrics.get('learning_contradiction_reviews', 0)}")
1510
+ lines.append("")
1511
+
1512
+ followup_deduplication = summary.get("followup_deduplication") or {}
1513
+ learning_consolidation = summary.get("learning_consolidation") or {}
1514
+ if followup_deduplication or learning_consolidation:
1515
+ lines.append("## Prevention Quality")
1516
+ lines.append("")
1517
+ if followup_deduplication:
1518
+ lines.append(f"- Open followups: {followup_deduplication.get('open_followups', 0)}")
1519
+ lines.append(f"- Duplicate open followups: {followup_deduplication.get('duplicate_open_followups', 0)}")
1520
+ if followup_deduplication.get("duplicate_rate_pct") is not None:
1521
+ lines.append(f"- Duplicate followup rate: {followup_deduplication['duplicate_rate_pct']:.1f}%")
1522
+ if learning_consolidation:
1523
+ lines.append(f"- Active learnings: {learning_consolidation.get('active_learnings', 0)}")
1524
+ lines.append(f"- Learning noise pressure: {learning_consolidation.get('noise_pressure', 0)}")
1525
+ if learning_consolidation.get("noise_rate_pct") is not None:
1526
+ lines.append(f"- Learning noise rate: {learning_consolidation['noise_rate_pct']:.1f}%")
1527
+ lines.append("")
1528
+
1085
1529
  if summary.get("top_projects"):
1086
1530
  lines.append("## Top Projects")
1087
1531
  lines.append("")
@@ -1091,6 +1535,20 @@ def _render_period_summary_markdown(summary: dict) -> str:
1091
1535
  lines.append(f" Reasons: {', '.join(item['reasons'])}")
1092
1536
  lines.append("")
1093
1537
 
1538
+ if summary.get("project_pulse"):
1539
+ lines.append("## Project Pulse")
1540
+ lines.append("")
1541
+ for item in summary["project_pulse"][:5]:
1542
+ delta = item.get("delta_vs_previous")
1543
+ delta_label = ""
1544
+ if isinstance(delta, (int, float)):
1545
+ delta_label = f" | Δ {delta:+.2f}"
1546
+ lines.append(
1547
+ f"- **{item['project']}** — {item.get('status', 'watch')} / {item.get('trend', 'steady')}"
1548
+ f" | score {item.get('score', 0)}{delta_label}"
1549
+ )
1550
+ lines.append("")
1551
+
1094
1552
  if summary.get("top_patterns"):
1095
1553
  lines.append("## Recurring Patterns")
1096
1554
  lines.append("")
@@ -1105,6 +1563,28 @@ def _render_period_summary_markdown(summary: dict) -> str:
1105
1563
  lines.append(f"- {item['title']} ({item['count']}x)")
1106
1564
  lines.append("")
1107
1565
 
1566
+ trend = summary.get("trend") or {}
1567
+ if trend.get("has_previous"):
1568
+ lines.append("## Trend vs Previous")
1569
+ lines.append("")
1570
+ if trend.get("avg_mood_delta") is not None:
1571
+ lines.append(f"- Mood delta: {trend['avg_mood_delta']:+.3f}")
1572
+ if trend.get("avg_trust_delta") is not None:
1573
+ lines.append(f"- Trust delta: {trend['avg_trust_delta']:+.1f}")
1574
+ if trend.get("total_corrections_delta") is not None:
1575
+ lines.append(f"- Corrections delta: {trend['total_corrections_delta']:+d}")
1576
+ if trend.get("protocol_compliance_delta") is not None:
1577
+ lines.append(f"- Protocol delta: {trend['protocol_compliance_delta']:+.1f}%")
1578
+ if trend.get("followup_duplicate_open_delta") is not None:
1579
+ lines.append(f"- Duplicate followups delta: {trend['followup_duplicate_open_delta']:+d}")
1580
+ if trend.get("followup_duplicate_rate_delta") is not None:
1581
+ lines.append(f"- Duplicate followup rate delta: {trend['followup_duplicate_rate_delta']:+.1f}%")
1582
+ if trend.get("learning_noise_delta") is not None:
1583
+ lines.append(f"- Learning noise delta: {trend['learning_noise_delta']:+d}")
1584
+ if trend.get("learning_noise_rate_delta") is not None:
1585
+ lines.append(f"- Learning noise rate delta: {trend['learning_noise_rate_delta']:+.1f}%")
1586
+ lines.append("")
1587
+
1108
1588
  return "\n".join(lines).rstrip() + "\n"
1109
1589
 
1110
1590
 
@@ -25,6 +25,7 @@ NEXO_CODE = Path(os.environ.get("NEXO_CODE", ""))
25
25
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
26
26
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
27
27
  COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
28
+ _TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
28
29
 
29
30
  MIN_USER_MESSAGES = 3 # Skip trivial sessions
30
31
 
@@ -304,6 +305,33 @@ def safe_query(db_path: Path, query: str, params: tuple = ()) -> list[dict]:
304
305
  return []
305
306
 
306
307
 
308
+ def _table_columns(db_path: Path, table_name: str) -> set[str]:
309
+ cache_key = (str(db_path), table_name)
310
+ cached = _TABLE_COLUMNS_CACHE.get(cache_key)
311
+ if cached is not None:
312
+ return cached
313
+ if not db_path.exists():
314
+ _TABLE_COLUMNS_CACHE[cache_key] = set()
315
+ return set()
316
+ try:
317
+ conn = sqlite3.connect(str(db_path))
318
+ conn.row_factory = sqlite3.Row
319
+ rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
320
+ conn.close()
321
+ except Exception:
322
+ _TABLE_COLUMNS_CACHE[cache_key] = set()
323
+ return set()
324
+ columns = {str(row["name"]) for row in rows}
325
+ _TABLE_COLUMNS_CACHE[cache_key] = columns
326
+ return columns
327
+
328
+
329
+ def _optional_column_sql(db_path: Path, table_name: str, column_name: str, default_sql: str = "''") -> str:
330
+ if column_name in _table_columns(db_path, table_name):
331
+ return column_name
332
+ return f"{default_sql} AS {column_name}"
333
+
334
+
307
335
  def collect_followups() -> list[dict]:
308
336
  """Active followups from nexo.db."""
309
337
  return safe_query(
@@ -513,9 +541,13 @@ def _project_priority_signals(target_day: datetime, compact_diaries: list[dict])
513
541
  for project in candidates:
514
542
  bump(project, 3.0 * recency_bonus, "diary_sessions", "recent session diary activity")
515
543
 
544
+ learning_priority_sql = _optional_column_sql(NEXO_DB, "learnings", "priority", "'medium'")
545
+ learning_weight_sql = _optional_column_sql(NEXO_DB, "learnings", "weight", "0")
546
+ learning_applies_sql = _optional_column_sql(NEXO_DB, "learnings", "applies_to", "''")
516
547
  learning_rows = safe_query(
517
548
  NEXO_DB,
518
- "SELECT category, title, content, created_at, updated_at, priority, weight, applies_to FROM learnings "
549
+ f"SELECT category, title, content, created_at, updated_at, {learning_priority_sql}, "
550
+ f"{learning_weight_sql}, {learning_applies_sql} FROM learnings "
519
551
  "ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 160",
520
552
  )
521
553
  for row in learning_rows:
@@ -535,9 +567,12 @@ def _project_priority_signals(target_day: datetime, compact_diaries: list[dict])
535
567
  for project in matched:
536
568
  bump(project, score, "learnings", "recent leverage-bearing learning")
537
569
 
570
+ followup_priority_sql = _optional_column_sql(NEXO_DB, "followups", "priority", "'medium'")
571
+ followup_reasoning_sql = _optional_column_sql(NEXO_DB, "followups", "reasoning", "''")
538
572
  followup_rows = safe_query(
539
573
  NEXO_DB,
540
- "SELECT id, description, date, status, priority, created_at, updated_at, reasoning FROM followups "
574
+ f"SELECT id, description, date, status, {followup_priority_sql}, created_at, updated_at, "
575
+ f"{followup_reasoning_sql} FROM followups "
541
576
  "WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 120",
542
577
  )
543
578
  for row in followup_rows:
@@ -565,9 +600,13 @@ def _project_priority_signals(target_day: datetime, compact_diaries: list[dict])
565
600
  for project in matched:
566
601
  bump(project, score, "followups", "open followup pressure")
567
602
 
603
+ decision_status_sql = _optional_column_sql(NEXO_DB, "decisions", "status", "''")
604
+ decision_reasoning_sql = _optional_column_sql(NEXO_DB, "decisions", "reasoning", "''")
605
+ decision_review_due_sql = _optional_column_sql(NEXO_DB, "decisions", "review_due_at", "NULL")
568
606
  decision_rows = safe_query(
569
607
  NEXO_DB,
570
- "SELECT domain, outcome, status, reasoning, created_at, review_due_at FROM decisions "
608
+ f"SELECT domain, outcome, {decision_status_sql}, {decision_reasoning_sql}, decision, based_on, created_at, "
609
+ f"{decision_review_due_sql} FROM decisions "
571
610
  "ORDER BY COALESCE(created_at, review_due_at) DESC LIMIT 120",
572
611
  )
573
612
  for row in decision_rows:
@@ -579,6 +618,8 @@ def _project_priority_signals(target_day: datetime, compact_diaries: list[dict])
579
618
  " ".join(
580
619
  [
581
620
  str(row.get("reasoning", "") or ""),
621
+ str(row.get("decision", "") or ""),
622
+ str(row.get("based_on", "") or ""),
582
623
  str(row.get("outcome", "") or ""),
583
624
  str(row.get("status", "") or ""),
584
625
  ]
@@ -647,9 +688,13 @@ def collect_long_horizon_context(
647
688
  recurring_states = Counter(row["mental_state"] for row in compact_diaries if row.get("mental_state"))
648
689
  recurring_critiques = Counter(row["self_critique"] for row in compact_diaries if row.get("self_critique"))
649
690
 
691
+ learning_reasoning_sql = _optional_column_sql(NEXO_DB, "learnings", "reasoning", "''")
692
+ learning_prevention_sql = _optional_column_sql(NEXO_DB, "learnings", "prevention", "''")
693
+ learning_applies_sql = _optional_column_sql(NEXO_DB, "learnings", "applies_to", "''")
650
694
  learning_rows = safe_query(
651
695
  NEXO_DB,
652
- "SELECT category, title, content, created_at, updated_at, reasoning, prevention, applies_to "
696
+ f"SELECT category, title, content, created_at, updated_at, {learning_reasoning_sql}, "
697
+ f"{learning_prevention_sql}, {learning_applies_sql} "
653
698
  "FROM learnings ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120"
654
699
  )
655
700
  long_horizon_learnings = []
@@ -29,6 +29,7 @@ def main(argv: list[str] | None = None) -> int:
29
29
  parser.add_argument("--prompt", default="", help="Prompt text")
30
30
  parser.add_argument("--prompt-file", default="", help="Read prompt text from a file")
31
31
  parser.add_argument("--cwd", default="", help="Working directory for the backend")
32
+ parser.add_argument("--task-profile", default="", help="Automation task profile: default|fast|balanced|deep")
32
33
  parser.add_argument("--model", default="", help="Backend model hint")
33
34
  parser.add_argument("--reasoning-effort", default="", help="Backend reasoning effort/profile")
34
35
  parser.add_argument("--timeout", type=int, default=21600, help="Timeout in seconds")
@@ -51,6 +52,7 @@ def main(argv: list[str] | None = None) -> int:
51
52
  result = run_automation_prompt(
52
53
  prompt,
53
54
  cwd=args.cwd or None,
55
+ task_profile=args.task_profile,
54
56
  model=args.model,
55
57
  reasoning_effort=args.reasoning_effort,
56
58
  timeout=args.timeout,