nexo-brain 5.3.19 → 5.3.21

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,800 @@
1
+ from __future__ import annotations
2
+ """NEXO DB — Outcome tracker v1."""
3
+
4
+ import datetime
5
+ import json
6
+ from typing import Any
7
+
8
+ from db._core import get_db
9
+
10
+ VALID_METRIC_SOURCES = {
11
+ "manual",
12
+ "followup_status",
13
+ "decision_outcome",
14
+ "protocol_task_status",
15
+ "nexo_sqlite",
16
+ }
17
+ VALID_TARGET_OPERATORS = {"gte", "lte", "eq"}
18
+ OUTCOME_PATTERN_MIN_RESOLVED = 3
19
+ OUTCOME_PATTERN_MAX_EVIDENCE = 5
20
+ OUTCOME_PATTERN_LEARNING_SUCCESS_BOOST = 0.9
21
+ OUTCOME_PATTERN_LEARNING_RISK_REDUCTION = 0.6
22
+ OUTCOME_PATTERN_LEARNING_SUCCESS_PENALTY = -1.1
23
+ OUTCOME_PATTERN_LEARNING_RISK_PENALTY = 0.8
24
+ OUTCOME_PATTERN_MIN_SUCCESS_RATE = 0.75
25
+ OUTCOME_PATTERN_MAX_FAILURE_RATE = 0.25
26
+
27
+
28
+ def _utcnow_iso() -> str:
29
+ return datetime.datetime.now().isoformat(timespec="seconds")
30
+
31
+
32
+ def _normalize_deadline(deadline: str = "", *, default_days: int = 7) -> str:
33
+ clean = (deadline or "").strip()
34
+ if clean:
35
+ return clean
36
+ return (datetime.datetime.now() + datetime.timedelta(days=default_days)).isoformat(timespec="seconds")
37
+
38
+
39
+ def _normalize_source(metric_source: str) -> str:
40
+ clean = (metric_source or "manual").strip().lower()
41
+ if clean not in VALID_METRIC_SOURCES:
42
+ raise ValueError(f"metric_source must be one of: {', '.join(sorted(VALID_METRIC_SOURCES))}")
43
+ return clean
44
+
45
+
46
+ def _normalize_operator(target_operator: str) -> str:
47
+ clean = (target_operator or "gte").strip().lower()
48
+ if clean not in VALID_TARGET_OPERATORS:
49
+ raise ValueError(f"target_operator must be one of: {', '.join(sorted(VALID_TARGET_OPERATORS))}")
50
+ return clean
51
+
52
+
53
+ def _as_float(value: Any) -> float | None:
54
+ if value is None or value == "":
55
+ return None
56
+ try:
57
+ return float(value)
58
+ except (TypeError, ValueError):
59
+ return None
60
+
61
+
62
+ def _append_note(existing: str | None, extra: str) -> str:
63
+ extra = (extra or "").strip()
64
+ if not extra:
65
+ return (existing or "").strip()
66
+ existing = (existing or "").strip()
67
+ if not existing:
68
+ return extra
69
+ if extra in existing:
70
+ return existing
71
+ return f"{existing}\n{extra}"
72
+
73
+
74
+ def _format_scalar(value: Any) -> str:
75
+ if value is None:
76
+ return ""
77
+ if isinstance(value, float):
78
+ return f"{value:.4f}".rstrip("0").rstrip(".")
79
+ return str(value)
80
+
81
+
82
+ def _pattern_key(*, area: str, task_type: str, goal_profile_id: str, selected_choice: str) -> str:
83
+ return json.dumps(
84
+ {
85
+ "area": (area or "").strip(),
86
+ "task_type": (task_type or "").strip(),
87
+ "goal_profile_id": (goal_profile_id or "").strip(),
88
+ "selected_choice": (selected_choice or "").strip(),
89
+ },
90
+ ensure_ascii=False,
91
+ sort_keys=True,
92
+ )
93
+
94
+
95
+ def _context_label(*, area: str, task_type: str, goal_profile_id: str) -> str:
96
+ bits = []
97
+ if (area or "").strip():
98
+ bits.append(f"area={area.strip()}")
99
+ if (task_type or "").strip():
100
+ bits.append(f"task_type={task_type.strip()}")
101
+ if (goal_profile_id or "").strip():
102
+ bits.append(f"profile={goal_profile_id.strip()}")
103
+ return ", ".join(bits) if bits else "contexto general"
104
+
105
+
106
+ def _compare(actual_value: float, target_value: float, operator: str) -> bool:
107
+ if operator == "gte":
108
+ return actual_value >= target_value
109
+ if operator == "lte":
110
+ return actual_value <= target_value
111
+ return actual_value == target_value
112
+
113
+
114
+ def _get_outcome_row(conn, outcome_id: int):
115
+ return conn.execute("SELECT * FROM outcomes WHERE id = ?", (int(outcome_id),)).fetchone()
116
+
117
+
118
+ def _update_outcome(
119
+ outcome_id: int,
120
+ *,
121
+ status: str | None = None,
122
+ actual_value: float | None = None,
123
+ actual_value_text: str | None = None,
124
+ checked_at: str | None = None,
125
+ notes: str | None = None,
126
+ learning_id: int | None = None,
127
+ ) -> dict:
128
+ conn = get_db()
129
+ row = _get_outcome_row(conn, outcome_id)
130
+ if not row:
131
+ return {"error": f"Outcome {outcome_id} not found"}
132
+
133
+ updates: list[str] = []
134
+ params: list[Any] = []
135
+
136
+ if status is not None:
137
+ updates.append("status = ?")
138
+ params.append(status)
139
+ if actual_value is not None:
140
+ updates.append("actual_value = ?")
141
+ params.append(actual_value)
142
+ if actual_value_text is not None:
143
+ updates.append("actual_value_text = ?")
144
+ params.append(actual_value_text.strip())
145
+ if checked_at is not None:
146
+ updates.append("checked_at = ?")
147
+ params.append(checked_at)
148
+ if notes is not None:
149
+ updates.append("notes = ?")
150
+ params.append(notes)
151
+ if learning_id is not None:
152
+ updates.append("learning_id = ?")
153
+ params.append(learning_id)
154
+
155
+ updates.append("updated_at = datetime('now')")
156
+ params.append(int(outcome_id))
157
+ conn.execute(f"UPDATE outcomes SET {', '.join(updates)} WHERE id = ?", params)
158
+ conn.commit()
159
+ row = _get_outcome_row(conn, outcome_id)
160
+ return dict(row) if row else {"error": f"Outcome {outcome_id} not found after update"}
161
+
162
+
163
+ def create_outcome(
164
+ action_type: str,
165
+ description: str,
166
+ expected_result: str,
167
+ *,
168
+ metric_source: str = "manual",
169
+ metric_query: str = "",
170
+ baseline_value: float | None = None,
171
+ target_value: float | None = None,
172
+ target_operator: str = "gte",
173
+ deadline: str = "",
174
+ action_id: str = "",
175
+ session_id: str = "",
176
+ notes: str = "",
177
+ ) -> dict:
178
+ conn = get_db()
179
+ clean_action_type = (action_type or "").strip()
180
+ clean_description = (description or "").strip()
181
+ clean_expected = (expected_result or "").strip()
182
+ if not clean_action_type:
183
+ return {"error": "action_type is required"}
184
+ if not clean_description:
185
+ return {"error": "description is required"}
186
+ if not clean_expected:
187
+ return {"error": "expected_result is required"}
188
+
189
+ try:
190
+ clean_source = _normalize_source(metric_source)
191
+ clean_operator = _normalize_operator(target_operator)
192
+ except ValueError as exc:
193
+ return {"error": str(exc)}
194
+
195
+ clean_query = (metric_query or "").strip()
196
+ if clean_source in {"followup_status", "decision_outcome", "protocol_task_status"} and not (action_id or "").strip():
197
+ return {"error": f"action_id is required for metric_source='{clean_source}'"}
198
+ if clean_source == "nexo_sqlite":
199
+ if not clean_query:
200
+ return {"error": "metric_query is required for metric_source='nexo_sqlite'"}
201
+ query_upper = clean_query.upper()
202
+ if ";" in clean_query.rstrip(";"):
203
+ return {"error": "metric_query must be a single SELECT statement"}
204
+ if not query_upper.startswith("SELECT "):
205
+ return {"error": "metric_query must start with SELECT"}
206
+ for forbidden in ("INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "ATTACH ", "DETACH ", "PRAGMA "):
207
+ if forbidden in query_upper:
208
+ return {"error": "metric_query must be read-only"}
209
+
210
+ cursor = conn.execute(
211
+ """INSERT INTO outcomes (
212
+ action_type, action_id, session_id, description, expected_result,
213
+ metric_source, metric_query, baseline_value, target_value,
214
+ target_operator, deadline, notes
215
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
216
+ (
217
+ clean_action_type,
218
+ (action_id or "").strip(),
219
+ (session_id or "").strip(),
220
+ clean_description,
221
+ clean_expected,
222
+ clean_source,
223
+ clean_query,
224
+ baseline_value,
225
+ target_value,
226
+ clean_operator,
227
+ _normalize_deadline(deadline),
228
+ (notes or "").strip(),
229
+ ),
230
+ )
231
+ conn.commit()
232
+ row = _get_outcome_row(conn, cursor.lastrowid)
233
+ return dict(row) if row else {"error": "Outcome insert failed"}
234
+
235
+
236
+ def get_outcome(outcome_id: int) -> dict | None:
237
+ conn = get_db()
238
+ row = _get_outcome_row(conn, outcome_id)
239
+ return dict(row) if row else None
240
+
241
+
242
+ def list_outcomes(status: str = "", action_type: str = "", limit: int = 50) -> list[dict]:
243
+ conn = get_db()
244
+ clauses = []
245
+ params: list[Any] = []
246
+ if status:
247
+ clauses.append("status = ?")
248
+ params.append((status or "").strip().lower())
249
+ if action_type:
250
+ clauses.append("action_type = ?")
251
+ params.append((action_type or "").strip())
252
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
253
+ rows = conn.execute(
254
+ f"""SELECT * FROM outcomes
255
+ {where}
256
+ ORDER BY
257
+ CASE status
258
+ WHEN 'pending' THEN 0
259
+ WHEN 'missed' THEN 1
260
+ WHEN 'met' THEN 2
261
+ ELSE 3
262
+ END,
263
+ deadline ASC,
264
+ created_at DESC
265
+ LIMIT ?""",
266
+ params + [max(1, int(limit))],
267
+ ).fetchall()
268
+ return [dict(row) for row in rows]
269
+
270
+
271
+ def pending_outcomes_due(deadline_before: str | None = None, limit: int = 100) -> list[dict]:
272
+ conn = get_db()
273
+ cutoff = deadline_before or _utcnow_iso()
274
+ rows = conn.execute(
275
+ """SELECT * FROM outcomes
276
+ WHERE status = 'pending' AND deadline <= ?
277
+ ORDER BY deadline ASC, id ASC
278
+ LIMIT ?""",
279
+ (cutoff, max(1, int(limit))),
280
+ ).fetchall()
281
+ return [dict(row) for row in rows]
282
+
283
+
284
+ def find_pending_outcomes_by_action(action_type: str, action_id: str, *, metric_source: str = "") -> list[dict]:
285
+ conn = get_db()
286
+ clauses = [
287
+ "status = 'pending'",
288
+ "action_type = ?",
289
+ "action_id = ?",
290
+ ]
291
+ params: list[Any] = [(action_type or "").strip(), (action_id or "").strip()]
292
+ if metric_source:
293
+ clauses.append("metric_source = ?")
294
+ params.append((metric_source or "").strip().lower())
295
+ rows = conn.execute(
296
+ f"SELECT * FROM outcomes WHERE {' AND '.join(clauses)} ORDER BY deadline ASC, id ASC",
297
+ params,
298
+ ).fetchall()
299
+ return [dict(row) for row in rows]
300
+
301
+
302
+ def cancel_outcome(outcome_id: int, reason: str = "") -> dict:
303
+ conn = get_db()
304
+ row = _get_outcome_row(conn, outcome_id)
305
+ if not row:
306
+ return {"error": f"Outcome {outcome_id} not found"}
307
+ notes = _append_note(row["notes"], f"Cancelled: {(reason or '').strip() or 'no reason provided'}")
308
+ return _update_outcome(
309
+ int(outcome_id),
310
+ status="cancelled",
311
+ checked_at=_utcnow_iso(),
312
+ notes=notes,
313
+ )
314
+
315
+
316
+ def _create_miss_learning(row: dict, actual_value: float | None, actual_value_text: str, note: str) -> int | None:
317
+ if row.get("learning_id"):
318
+ return int(row["learning_id"])
319
+ from db._learnings import create_learning
320
+
321
+ summary_bits = [
322
+ f"Outcome #{row['id']} missed.",
323
+ f"Action type: {row.get('action_type', '')}.",
324
+ f"Action id: {row.get('action_id', '') or 'N/A'}.",
325
+ f"Description: {row.get('description', '')}.",
326
+ f"Expected: {row.get('expected_result', '')}.",
327
+ ]
328
+ if actual_value is not None:
329
+ summary_bits.append(f"Actual numeric value: {actual_value}.")
330
+ if actual_value_text:
331
+ summary_bits.append(f"Actual evidence: {actual_value_text}.")
332
+ if note:
333
+ summary_bits.append(f"Why missed: {note}.")
334
+ learning = create_learning(
335
+ category="outcomes",
336
+ title=f"Outcome missed: {str(row.get('description', ''))[:80]}",
337
+ content=" ".join(summary_bits),
338
+ reasoning=f"Auto-created from missed outcome #{row['id']}.",
339
+ prevention="Review the action, expected result, target, or deadline before repeating the same move.",
340
+ applies_to=f"outcome:{row['id']}",
341
+ )
342
+ return int(learning["id"]) if learning and learning.get("id") else None
343
+
344
+
345
+ def _status_from_protocol_task(task_row: dict | None, *, deadline_passed: bool) -> tuple[str, float | None, str, str]:
346
+ if not task_row:
347
+ if deadline_passed:
348
+ return "missed", None, "protocol task missing", "Linked protocol task not found."
349
+ return "pending", None, "", ""
350
+ status = str(task_row.get("status") or "").strip().lower()
351
+ if status == "done":
352
+ evidence = (task_row.get("outcome_notes") or task_row.get("close_evidence") or task_row.get("goal") or "").strip()
353
+ return "met", 1.0, evidence, "Linked protocol task closed as done."
354
+ if status in {"failed", "cancelled"}:
355
+ evidence = (task_row.get("outcome_notes") or task_row.get("close_evidence") or status).strip()
356
+ return "missed", 0.0, evidence, f"Linked protocol task closed as {status}."
357
+ if deadline_passed:
358
+ return "missed", None, status or "open", f"Deadline passed while linked protocol task remained {status or 'open'}."
359
+ return "pending", None, status, ""
360
+
361
+
362
+ def evaluate_outcome(
363
+ outcome_id: int,
364
+ *,
365
+ actual_value: float | None = None,
366
+ actual_value_text: str = "",
367
+ create_learning_on_miss: bool = True,
368
+ ) -> dict:
369
+ conn = get_db()
370
+ row = _get_outcome_row(conn, outcome_id)
371
+ if not row:
372
+ return {"error": f"Outcome {outcome_id} not found"}
373
+ row_d = dict(row)
374
+ if row_d.get("status") != "pending":
375
+ row_d["evaluation"] = "skipped_non_pending"
376
+ return row_d
377
+
378
+ now_iso = _utcnow_iso()
379
+ deadline_passed = str(row_d.get("deadline") or "") <= now_iso
380
+ source = (row_d.get("metric_source") or "manual").strip().lower()
381
+ target = _as_float(row_d.get("target_value"))
382
+ operator = (row_d.get("target_operator") or "gte").strip().lower()
383
+
384
+ status = "pending"
385
+ actual_num = _as_float(actual_value)
386
+ actual_text = (actual_value_text or "").strip()
387
+ note = ""
388
+
389
+ if source == "manual":
390
+ if actual_num is not None:
391
+ actual_text = actual_text or _format_scalar(actual_num)
392
+ if target is None:
393
+ status = "met"
394
+ note = "Manual evidence recorded."
395
+ elif _compare(actual_num, target, operator):
396
+ status = "met"
397
+ note = f"Manual check met target via operator '{operator}'."
398
+ elif deadline_passed:
399
+ status = "missed"
400
+ note = f"Manual check below target and deadline passed (actual={actual_num}, target={target}, op={operator})."
401
+ else:
402
+ note = f"Manual check recorded current value (actual={actual_num}, target={target}, op={operator})."
403
+ elif actual_text:
404
+ status = "met"
405
+ note = "Manual textual evidence recorded."
406
+ elif deadline_passed:
407
+ status = "missed"
408
+ note = "Deadline passed with no manual evidence recorded."
409
+
410
+ elif source == "followup_status":
411
+ followup = conn.execute(
412
+ "SELECT status, description, verification FROM followups WHERE id = ?",
413
+ (row_d.get("action_id", ""),),
414
+ ).fetchone()
415
+ followup_status = str(followup["status"]) if followup else ""
416
+ if followup and followup_status.upper().startswith("COMPLETED"):
417
+ status = "met"
418
+ actual_num = 1.0
419
+ actual_text = (followup["verification"] or followup["description"] or followup_status or "").strip()
420
+ note = "Linked followup completed."
421
+ elif deadline_passed:
422
+ status = "missed"
423
+ actual_text = actual_text or (followup_status or "missing")
424
+ note = f"Deadline passed while linked followup remained {followup_status or 'missing'}."
425
+
426
+ elif source == "decision_outcome":
427
+ decision = conn.execute(
428
+ "SELECT outcome, status, decision FROM decisions WHERE id = ?",
429
+ (row_d.get("action_id", ""),),
430
+ ).fetchone()
431
+ decision_outcome = (decision["outcome"] or "").strip() if decision else ""
432
+ if decision and decision_outcome:
433
+ status = "met"
434
+ actual_num = 1.0
435
+ actual_text = decision_outcome
436
+ note = "Linked decision outcome recorded."
437
+ elif deadline_passed:
438
+ status = "missed"
439
+ actual_text = actual_text or (str(decision["status"]) if decision else "missing")
440
+ note = f"Deadline passed with no linked decision outcome for decision {row_d.get('action_id', '') or 'N/A'}."
441
+
442
+ elif source == "protocol_task_status":
443
+ task = conn.execute(
444
+ "SELECT status, goal, close_evidence, outcome_notes FROM protocol_tasks WHERE task_id = ?",
445
+ (row_d.get("action_id", ""),),
446
+ ).fetchone()
447
+ status, actual_num, actual_text, note = _status_from_protocol_task(dict(task) if task else None, deadline_passed=deadline_passed)
448
+
449
+ elif source == "nexo_sqlite":
450
+ query = (row_d.get("metric_query") or "").strip()
451
+ if not query:
452
+ return {"error": f"Outcome {outcome_id} has empty metric_query"}
453
+ if ";" in query.rstrip(";") or not query.upper().startswith("SELECT "):
454
+ return {"error": "Outcome metric_query must be a single SELECT statement"}
455
+ fetched = conn.execute(query).fetchone()
456
+ scalar = fetched[0] if fetched else None
457
+ actual_num = _as_float(scalar)
458
+ actual_text = _format_scalar(scalar)
459
+ if actual_num is None:
460
+ if deadline_passed:
461
+ status = "missed"
462
+ note = f"SQLite query did not return a numeric scalar before deadline. Got: {actual_text or 'empty'}."
463
+ else:
464
+ note = f"SQLite query returned non-numeric scalar: {actual_text or 'empty'}."
465
+ elif target is None:
466
+ if actual_num:
467
+ status = "met"
468
+ note = f"SQLite query returned truthy scalar {actual_num}."
469
+ elif deadline_passed:
470
+ status = "missed"
471
+ note = "SQLite query returned falsy scalar and deadline passed."
472
+ else:
473
+ note = f"SQLite query current scalar is {actual_num}."
474
+ elif _compare(actual_num, target, operator):
475
+ status = "met"
476
+ note = f"SQLite scalar met target (actual={actual_num}, target={target}, op={operator})."
477
+ elif deadline_passed:
478
+ status = "missed"
479
+ note = f"SQLite scalar missed target at deadline (actual={actual_num}, target={target}, op={operator})."
480
+ else:
481
+ note = f"SQLite scalar recorded but target not reached yet (actual={actual_num}, target={target}, op={operator})."
482
+
483
+ learning_id = row_d.get("learning_id")
484
+ combined_notes = _append_note(row_d.get("notes"), note)
485
+ if status == "missed" and create_learning_on_miss:
486
+ learning_id = _create_miss_learning(row_d, actual_num, actual_text, note)
487
+
488
+ updated = _update_outcome(
489
+ int(outcome_id),
490
+ status=status,
491
+ actual_value=actual_num,
492
+ actual_value_text=actual_text,
493
+ checked_at=now_iso,
494
+ notes=combined_notes,
495
+ learning_id=int(learning_id) if learning_id else None,
496
+ )
497
+ if "error" not in updated:
498
+ updated["evaluation"] = status
499
+ return updated
500
+
501
+
502
+ def set_linked_outcomes_met(
503
+ action_type: str,
504
+ action_id: str,
505
+ *,
506
+ metric_source: str = "",
507
+ actual_value: float | None = 1.0,
508
+ actual_value_text: str = "",
509
+ note: str = "",
510
+ ) -> list[dict]:
511
+ rows = find_pending_outcomes_by_action(action_type, action_id, metric_source=metric_source)
512
+ updated: list[dict] = []
513
+ for row in rows:
514
+ updated.append(
515
+ _update_outcome(
516
+ int(row["id"]),
517
+ status="met",
518
+ actual_value=actual_value,
519
+ actual_value_text=actual_value_text or row.get("actual_value_text", ""),
520
+ checked_at=_utcnow_iso(),
521
+ notes=_append_note(row.get("notes"), note or "Linked action reached success state."),
522
+ )
523
+ )
524
+ return updated
525
+
526
+
527
+ def list_outcome_pattern_candidates(
528
+ *,
529
+ min_resolved: int = OUTCOME_PATTERN_MIN_RESOLVED,
530
+ min_success_rate: float = OUTCOME_PATTERN_MIN_SUCCESS_RATE,
531
+ max_failure_rate: float = OUTCOME_PATTERN_MAX_FAILURE_RATE,
532
+ limit: int = 20,
533
+ ) -> list[dict]:
534
+ conn = get_db()
535
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_evaluations'").fetchone():
536
+ return []
537
+
538
+ rows = conn.execute(
539
+ """SELECT
540
+ e.area,
541
+ e.task_type,
542
+ e.goal_profile_id,
543
+ e.selected_choice,
544
+ SUM(CASE WHEN o.status = 'met' THEN 1 ELSE 0 END) AS met,
545
+ SUM(CASE WHEN o.status = 'missed' THEN 1 ELSE 0 END) AS missed,
546
+ COUNT(*) AS resolved_outcomes,
547
+ MAX(e.created_at) AS last_seen_at
548
+ FROM cortex_evaluations e
549
+ JOIN outcomes o ON o.id = e.linked_outcome_id
550
+ WHERE (e.selected_choice IS NOT NULL AND trim(e.selected_choice) != '')
551
+ AND o.status IN ('met', 'missed')
552
+ GROUP BY e.area, e.task_type, e.goal_profile_id, e.selected_choice
553
+ HAVING COUNT(*) >= ?
554
+ ORDER BY resolved_outcomes DESC, last_seen_at DESC
555
+ LIMIT ?""",
556
+ (max(1, int(min_resolved)), max(1, int(limit * 3))),
557
+ ).fetchall()
558
+
559
+ candidates: list[dict] = []
560
+ for row in rows:
561
+ resolved = int(row["resolved_outcomes"] or 0)
562
+ met = int(row["met"] or 0)
563
+ missed = int(row["missed"] or 0)
564
+ if resolved <= 0:
565
+ continue
566
+ success_rate = round(met / resolved, 3)
567
+ candidate_type = ""
568
+ if success_rate >= float(min_success_rate):
569
+ candidate_type = "reinforce_strategy"
570
+ elif success_rate <= float(max_failure_rate):
571
+ candidate_type = "avoid_strategy"
572
+ if not candidate_type:
573
+ continue
574
+
575
+ evidence_rows = conn.execute(
576
+ """SELECT e.id AS evaluation_id, o.id AS outcome_id, o.status, o.description, e.created_at
577
+ FROM cortex_evaluations e
578
+ JOIN outcomes o ON o.id = e.linked_outcome_id
579
+ WHERE e.area = ?
580
+ AND e.task_type = ?
581
+ AND e.goal_profile_id = ?
582
+ AND e.selected_choice = ?
583
+ AND o.status IN ('met', 'missed')
584
+ ORDER BY e.created_at DESC, e.id DESC
585
+ LIMIT ?""",
586
+ (
587
+ row["area"] or "",
588
+ row["task_type"] or "",
589
+ row["goal_profile_id"] or "",
590
+ row["selected_choice"] or "",
591
+ OUTCOME_PATTERN_MAX_EVIDENCE,
592
+ ),
593
+ ).fetchall()
594
+ evidence = [dict(item) for item in evidence_rows]
595
+ context = _context_label(
596
+ area=row["area"] or "",
597
+ task_type=row["task_type"] or "",
598
+ goal_profile_id=row["goal_profile_id"] or "",
599
+ )
600
+ selected_choice = (row["selected_choice"] or "").strip()
601
+ if candidate_type == "reinforce_strategy":
602
+ rationale = (
603
+ f"La estrategia '{selected_choice}' acumula {met}/{resolved} outcomes met en {context}."
604
+ )
605
+ else:
606
+ rationale = (
607
+ f"La estrategia '{selected_choice}' acumula {missed}/{resolved} outcomes missed en {context}."
608
+ )
609
+
610
+ pattern_key = _pattern_key(
611
+ area=row["area"] or "",
612
+ task_type=row["task_type"] or "",
613
+ goal_profile_id=row["goal_profile_id"] or "",
614
+ selected_choice=selected_choice,
615
+ )
616
+ candidates.append(
617
+ {
618
+ "pattern_key": pattern_key,
619
+ "candidate_type": candidate_type,
620
+ "area": row["area"] or "",
621
+ "task_type": row["task_type"] or "",
622
+ "goal_profile_id": row["goal_profile_id"] or "",
623
+ "selected_choice": selected_choice,
624
+ "resolved_outcomes": resolved,
625
+ "met": met,
626
+ "missed": missed,
627
+ "success_rate": success_rate,
628
+ "last_seen_at": row["last_seen_at"],
629
+ "context_label": context,
630
+ "rationale": rationale,
631
+ "evidence": evidence,
632
+ "suggested_skill_candidate": (
633
+ candidate_type == "reinforce_strategy" and resolved >= max(4, int(min_resolved))
634
+ ),
635
+ }
636
+ )
637
+
638
+ candidates.sort(
639
+ key=lambda item: (
640
+ 0 if item["candidate_type"] == "avoid_strategy" else 1,
641
+ -item["resolved_outcomes"],
642
+ item["selected_choice"],
643
+ )
644
+ )
645
+ return candidates[: max(1, int(limit))]
646
+
647
+
648
+ def capture_outcome_pattern(
649
+ pattern_key: str,
650
+ *,
651
+ target: str = "learning",
652
+ category: str = "outcomes",
653
+ ) -> dict:
654
+ clean_target = (target or "learning").strip().lower()
655
+ if clean_target != "learning":
656
+ return {"error": f"Unsupported target: {target}"}
657
+
658
+ clean_key = (pattern_key or "").strip()
659
+ if not clean_key:
660
+ return {"error": "pattern_key is required"}
661
+
662
+ candidates = list_outcome_pattern_candidates(limit=200)
663
+ candidate = next((item for item in candidates if item["pattern_key"] == clean_key), None)
664
+ if not candidate:
665
+ return {"error": "Pattern candidate not found or no longer qualifies"}
666
+
667
+ selected_choice = candidate["selected_choice"]
668
+ context = candidate["context_label"]
669
+ applies_to = f"outcome-pattern:{clean_key}"
670
+ if candidate["candidate_type"] == "reinforce_strategy":
671
+ title = f"Prefer {selected_choice} in {context}"
672
+ prevention = (
673
+ f"When a comparable context appears, default to '{selected_choice}' unless fresh evidence or constraints override it."
674
+ )
675
+ else:
676
+ title = f"Avoid {selected_choice} in {context}"
677
+ prevention = (
678
+ f"When a comparable context appears, do not default to '{selected_choice}' until the evidence base changes."
679
+ )
680
+
681
+ conn = get_db()
682
+ existing = conn.execute(
683
+ """SELECT * FROM learnings
684
+ WHERE status = 'active' AND applies_to = ?
685
+ ORDER BY updated_at DESC, id DESC
686
+ LIMIT 1""",
687
+ (applies_to,),
688
+ ).fetchone()
689
+ if existing:
690
+ return {
691
+ "ok": True,
692
+ "created": False,
693
+ "target": clean_target,
694
+ "candidate": candidate,
695
+ "learning": dict(existing),
696
+ }
697
+
698
+ evidence_refs = ", ".join(
699
+ f"eval#{item['evaluation_id']}/outcome#{item['outcome_id']}:{item['status']}"
700
+ for item in candidate["evidence"][:OUTCOME_PATTERN_MAX_EVIDENCE]
701
+ )
702
+ content = (
703
+ f"{candidate['rationale']} "
704
+ f"Success rate: {candidate['success_rate']:.3f}. "
705
+ f"Resolved outcomes: {candidate['resolved_outcomes']} "
706
+ f"(met={candidate['met']}, missed={candidate['missed']}). "
707
+ f"Evidence: {evidence_refs or 'none'}."
708
+ )
709
+ reasoning = (
710
+ "Structured outcome pattern captured from repeated resolved cortex-linked outcomes. "
711
+ f"Pattern key: {clean_key}."
712
+ )
713
+ from db._learnings import create_learning
714
+
715
+ learning = create_learning(
716
+ category=(category or "outcomes").strip(),
717
+ title=title,
718
+ content=content,
719
+ reasoning=reasoning,
720
+ prevention=prevention,
721
+ applies_to=applies_to,
722
+ )
723
+ return {
724
+ "ok": True,
725
+ "created": True,
726
+ "target": clean_target,
727
+ "candidate": candidate,
728
+ "learning": learning,
729
+ }
730
+
731
+
732
+ def get_outcome_pattern_learning_signal(
733
+ *,
734
+ area: str = "",
735
+ task_type: str = "",
736
+ goal_profile_id: str = "",
737
+ selected_choice: str = "",
738
+ ) -> dict:
739
+ clean_choice = (selected_choice or "").strip()
740
+ if not clean_choice:
741
+ return {
742
+ "active": False,
743
+ "pattern_key": "",
744
+ "learning_id": 0,
745
+ "mode": "",
746
+ "title": "",
747
+ "success_adjustment": 0.0,
748
+ "risk_adjustment": 0.0,
749
+ }
750
+
751
+ pattern_key = _pattern_key(
752
+ area=area or "",
753
+ task_type=task_type or "",
754
+ goal_profile_id=goal_profile_id or "",
755
+ selected_choice=clean_choice,
756
+ )
757
+ applies_to = f"outcome-pattern:{pattern_key}"
758
+ conn = get_db()
759
+ row = conn.execute(
760
+ """SELECT id, title
761
+ FROM learnings
762
+ WHERE status = 'active' AND applies_to = ?
763
+ ORDER BY updated_at DESC, id DESC
764
+ LIMIT 1""",
765
+ (applies_to,),
766
+ ).fetchone()
767
+ if not row:
768
+ return {
769
+ "active": False,
770
+ "pattern_key": pattern_key,
771
+ "learning_id": 0,
772
+ "mode": "",
773
+ "title": "",
774
+ "success_adjustment": 0.0,
775
+ "risk_adjustment": 0.0,
776
+ }
777
+
778
+ title = str(row["title"] or "").strip()
779
+ if title.startswith("Prefer "):
780
+ mode = "prefer"
781
+ success_adjustment = OUTCOME_PATTERN_LEARNING_SUCCESS_BOOST
782
+ risk_adjustment = -OUTCOME_PATTERN_LEARNING_RISK_REDUCTION
783
+ elif title.startswith("Avoid "):
784
+ mode = "avoid"
785
+ success_adjustment = OUTCOME_PATTERN_LEARNING_SUCCESS_PENALTY
786
+ risk_adjustment = OUTCOME_PATTERN_LEARNING_RISK_PENALTY
787
+ else:
788
+ mode = "observe"
789
+ success_adjustment = 0.0
790
+ risk_adjustment = 0.0
791
+
792
+ return {
793
+ "active": mode in {"prefer", "avoid"},
794
+ "pattern_key": pattern_key,
795
+ "learning_id": int(row["id"] or 0),
796
+ "mode": mode,
797
+ "title": title,
798
+ "success_adjustment": round(success_adjustment, 2),
799
+ "risk_adjustment": round(risk_adjustment, 2),
800
+ }