nexo-brain 5.3.13 → 5.3.15

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 (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -1
  3. package/package.json +1 -1
  4. package/src/crons/sync.py +18 -4
  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/_entities.py +1 -1
  43. package/src/db/_episodic 2.py +762 -0
  44. package/src/db/_evolution 2.py +54 -0
  45. package/src/db/_fts 2.py +406 -0
  46. package/src/db/_goal_profiles 2.py +376 -0
  47. package/src/db/_hot_context 2.py +660 -0
  48. package/src/db/_outcomes 2.py +800 -0
  49. package/src/db/_personal_scripts 2.py +582 -0
  50. package/src/db/_sessions 2.py +330 -0
  51. package/src/db/_tasks 2.py +91 -0
  52. package/src/db/_watchers 2.py +173 -0
  53. package/src/doctor/formatters 2.py +52 -0
  54. package/src/doctor/models 2.py +69 -0
  55. package/src/doctor/planes 2.py +87 -0
  56. package/src/doctor/providers/__init__ 2.py +1 -0
  57. package/src/doctor/providers/deep 2.py +367 -0
  58. package/src/evolution_cycle 2.py +519 -0
  59. package/src/hooks/auto_capture 2.py +208 -0
  60. package/src/hooks/caffeinate-guard 2.sh +8 -0
  61. package/src/hooks/capture-session 2.sh +21 -0
  62. package/src/hooks/capture-tool-logs 2.sh +158 -0
  63. package/src/hooks/daily-briefing-check 2.sh +33 -0
  64. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  65. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  66. package/src/hooks/inbox-hook 2.sh +76 -0
  67. package/src/hooks/post-compact 2.sh +152 -0
  68. package/src/hooks/pre-compact 2.sh +169 -0
  69. package/src/hooks/protocol-guardrail 2.sh +10 -0
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  71. package/src/hooks/session-stop 2.sh +52 -0
  72. package/src/kg_populate 2.py +292 -0
  73. package/src/maintenance 2.py +53 -0
  74. package/src/memory_backends 2.py +71 -0
  75. package/src/migrate_embeddings 2.py +124 -0
  76. package/src/nexo_sdk 2.py +103 -0
  77. package/src/observability 2.py +199 -0
  78. package/src/plugin_loader 2.py +217 -0
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/agents.py +10 -3
  81. package/src/plugins/artifact_registry 2.py +450 -0
  82. package/src/plugins/backup 2.py +127 -0
  83. package/src/plugins/claims_tools 2.py +119 -0
  84. package/src/plugins/cognitive_memory 2.py +609 -0
  85. package/src/plugins/core_rules 2.py +252 -0
  86. package/src/plugins/cortex 2.py +1155 -0
  87. package/src/plugins/entities 2.py +67 -0
  88. package/src/plugins/episodic_memory 2.py +560 -0
  89. package/src/plugins/evolution 2.py +167 -0
  90. package/src/plugins/goal_engine 2.py +142 -0
  91. package/src/plugins/guard 2.py +862 -0
  92. package/src/plugins/impact 2.py +29 -0
  93. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  94. package/src/plugins/media_memory_tools 2.py +98 -0
  95. package/src/plugins/memory_export 2.py +196 -0
  96. package/src/plugins/outcomes 2.py +130 -0
  97. package/src/plugins/personal_scripts 2.py +117 -0
  98. package/src/plugins/preferences 2.py +47 -0
  99. package/src/plugins/protocol 2.py +1449 -0
  100. package/src/plugins/schedule.py +2 -1
  101. package/src/plugins/simple_api 2.py +106 -0
  102. package/src/plugins/skills 2.py +341 -0
  103. package/src/plugins/state_watchers 2.py +79 -0
  104. package/src/plugins/update 2.py +986 -0
  105. package/src/plugins/user_state_tools 2.py +43 -0
  106. package/src/plugins/workflow 2.py +588 -0
  107. package/src/protocol_settings 2.py +59 -0
  108. package/src/public_contribution 2.py +466 -0
  109. package/src/public_evolution_queue 2.py +241 -0
  110. package/src/requirements 2.txt +14 -0
  111. package/src/requirements.txt +1 -1
  112. package/src/retroactive_learnings 2.py +373 -0
  113. package/src/rules/__init__ 2.py +0 -0
  114. package/src/rules/core-rules 2.json +331 -0
  115. package/src/rules/migrate 2.py +207 -0
  116. package/src/runtime_power 2.py +874 -0
  117. package/src/runtime_power.py +18 -1
  118. package/src/script_registry 2.py +1559 -0
  119. package/src/scripts/check-context 2.py +272 -0
  120. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  121. package/src/scripts/deep-sleep/collect 2.py +928 -0
  122. package/src/scripts/deep-sleep/extract 2.py +330 -0
  123. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  124. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  125. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  126. package/src/scripts/nexo-agent-run 2.py +75 -0
  127. package/src/scripts/nexo-auto-update 2.py +6 -0
  128. package/src/scripts/nexo-backup 2.sh +25 -0
  129. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  130. package/src/scripts/nexo-catchup 2.py +300 -0
  131. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  132. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  133. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  134. package/src/scripts/nexo-cron-wrapper.sh +7 -0
  135. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  136. package/src/scripts/nexo-dashboard 2.sh +29 -0
  137. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  138. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  139. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  140. package/src/scripts/nexo-hook-record 2.py +42 -0
  141. package/src/scripts/nexo-immune 2.py +936 -0
  142. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  143. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  144. package/src/scripts/nexo-install 2.py +6 -0
  145. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  146. package/src/scripts/nexo-learning-validator 2.py +266 -0
  147. package/src/scripts/nexo-migrate 2.py +260 -0
  148. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  149. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  150. package/src/scripts/nexo-pre-commit 2.py +120 -0
  151. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  152. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  153. package/src/scripts/nexo-reflection 2.py +256 -0
  154. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  155. package/src/scripts/nexo-sleep 2.py +631 -0
  156. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  157. package/src/scripts/nexo-sync-clients 2.py +16 -0
  158. package/src/scripts/nexo-synthesis 2.py +475 -0
  159. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  160. package/src/scripts/nexo-update 2.sh +306 -0
  161. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  162. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  163. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  164. package/src/server 2.py +1296 -0
  165. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  166. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  167. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  168. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  169. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  170. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  171. package/src/skills/run-release-final-audit/script 2.py +259 -0
  172. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  173. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  174. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  175. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  176. package/src/skills_runtime 2.py +932 -0
  177. package/src/state_watchers_runtime 2.py +475 -0
  178. package/src/storage_router 2.py +32 -0
  179. package/src/system_catalog 2.py +786 -0
  180. package/src/tools_coordination 2.py +103 -0
  181. package/src/tools_credentials 2.py +68 -0
  182. package/src/tools_drive 2.py +487 -0
  183. package/src/tools_hot_context 2.py +163 -0
  184. package/src/tools_learnings 2.py +612 -0
  185. package/src/tools_menu 2.py +229 -0
  186. package/src/tools_reminders 2.py +88 -0
  187. package/src/tools_reminders_crud 2.py +363 -0
  188. package/src/tools_sessions 2.py +1054 -0
  189. package/src/tools_system_catalog 2.py +19 -0
  190. package/src/tools_task_history 2.py +57 -0
  191. package/src/tools_transcripts 2.py +98 -0
  192. package/src/transcript_utils 2.py +412 -0
  193. package/src/user_context 2.py +46 -0
  194. package/src/user_data_portability 2.py +328 -0
  195. package/src/user_state_model 2.py +170 -0
  196. package/templates/CLAUDE.md 2.template +108 -0
  197. package/templates/CODEX.AGENTS.md 2.template +66 -0
  198. package/templates/launchagents/README 2.md +132 -0
  199. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  200. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  201. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  202. package/templates/launchagents/com.nexo.catchup.plist +1 -1
  203. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  204. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  205. package/templates/launchagents/com.nexo.dashboard.plist +1 -1
  206. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  207. package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
  208. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  209. package/templates/launchagents/com.nexo.evolution.plist +1 -1
  210. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  211. package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
  212. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  213. package/templates/launchagents/com.nexo.immune.plist +1 -1
  214. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  215. package/templates/launchagents/com.nexo.postmortem.plist +1 -1
  216. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  217. package/templates/launchagents/com.nexo.self-audit.plist +1 -1
  218. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  219. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  220. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  221. package/templates/launchagents/com.nexo.watchdog.plist +1 -1
  222. package/templates/nexo_helper 2.py +301 -0
  223. package/templates/openclaw 2.json +13 -0
  224. package/templates/plugin-template 2.py +40 -0
  225. package/templates/script-template 2.py +59 -0
  226. package/templates/script-template 2.sh +13 -0
  227. package/templates/script-template.py +5 -4
  228. package/templates/skill-script-template 2.py +48 -0
  229. package/templates/skill-script-template.py +2 -1
  230. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,1155 @@
1
+ """Cognitive Cortex plugin — middleware cognitive layer for NEXO Brain.
2
+
3
+ Provides structured pre-action reasoning with architectural inhibitory control.
4
+ The Cortex does NOT generate answers — it gates, plans, and validates actions.
5
+
6
+ Activation: event-driven, not on every turn. Only on:
7
+ - Tool intent (edit, execute, delegate)
8
+ - Ambiguity in user request
9
+ - Destructive actions
10
+ - Multi-step tasks
11
+ - Retry after failure
12
+ - Contradictions with known facts
13
+
14
+ v0.1: Single MCP tool + middleware validation.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import secrets
21
+ import time
22
+ from datetime import datetime, timedelta
23
+ from pathlib import Path
24
+
25
+ from db import VALID_IMPACT_LEVELS, VALID_TASK_TYPES, validate_impact_level, validate_task_type
26
+
27
+
28
+ def _get_db():
29
+ from db import get_db
30
+ return get_db()
31
+
32
+
33
+ def _get_core_rules_for_task(task_type: str) -> list[str]:
34
+ """Get relevant Core Rules for the given task type."""
35
+ conn = _get_db()
36
+ try:
37
+ # Map task type to rule categories
38
+ category_map = {
39
+ "edit": ["integrity", "execution"],
40
+ "execute": ["integrity", "execution", "delegation"],
41
+ "delegate": ["delegation"],
42
+ "analyze": ["execution", "memory"],
43
+ "answer": ["communication"],
44
+ }
45
+ categories = category_map.get(task_type, ["integrity", "execution"])
46
+ placeholders = ",".join("?" * len(categories))
47
+
48
+ rows = conn.execute(
49
+ f"SELECT id, rule FROM core_rules WHERE category IN ({placeholders}) AND is_active = 1 AND type = 'blocking' ORDER BY importance DESC LIMIT 5",
50
+ categories
51
+ ).fetchall()
52
+ return [f"{r['id']}: {r['rule']}" for r in rows]
53
+ except Exception:
54
+ return []
55
+
56
+
57
+ def _get_trust_score() -> float:
58
+ """Get current trust score from cognitive.db."""
59
+ try:
60
+ import cognitive
61
+ return cognitive.get_trust_score()
62
+ except Exception:
63
+ return 50.0
64
+
65
+
66
+ SAFE_TERMS = {
67
+ "verify", "verification", "test", "smoke", "rollback", "monitor",
68
+ "staged", "stage", "incremental", "safe", "guard", "contract",
69
+ "document", "docs", "reconcile", "doctor",
70
+ }
71
+ RISK_TERMS = {
72
+ "force", "delete", "bypass", "skip", "manual", "direct", "hotfix",
73
+ "reset", "hardcode", "production", "launchagent", "plist",
74
+ }
75
+ DIRECT_IMPACT_TERMS = {
76
+ "fix", "close", "resolve", "ship", "release", "deploy", "migrate",
77
+ "automate", "integrate", "register", "repair",
78
+ }
79
+ POSITIVE_OUTCOME_TERMS = {
80
+ "met", "success", "resolved", "clean", "improved", "green", "healthy", "done",
81
+ }
82
+ NEGATIVE_OUTCOME_TERMS = {
83
+ "missed", "failed", "failure", "regressed", "blocked", "error", "degraded",
84
+ }
85
+ STOP_WORDS = {
86
+ "about", "after", "again", "before", "being", "between", "could", "should",
87
+ "there", "their", "would", "while", "using", "used", "from", "with",
88
+ "that", "this", "into", "over", "have", "must", "will", "your",
89
+ }
90
+ HISTORICAL_OUTCOME_MIN_RESOLVED = 2
91
+ HISTORICAL_OUTCOME_LOOKBACK = 12
92
+
93
+
94
+ def _term_hits(text: str, terms: set[str]) -> int:
95
+ lowered = (text or "").lower()
96
+ return sum(1 for term in terms if term in lowered)
97
+
98
+
99
+ def _validate_state(state: dict) -> dict:
100
+ """Validate cognitive state and determine action mode.
101
+
102
+ Returns dict with: mode, warnings, injected_rules, blocked_reason
103
+ """
104
+ warnings = []
105
+ mode = "act" # default: allow action
106
+ blocked_reason = None
107
+
108
+ task_type = state.get("task_type", "answer")
109
+ plan = state.get("plan", [])
110
+ unknowns = state.get("unknowns", [])
111
+ evidence = state.get("evidence_refs", [])
112
+ verification = state.get("verification_step", "")
113
+ constraints = state.get("constraints", [])
114
+ goal = state.get("goal", "")
115
+
116
+ # === INHIBITION RULES (architectural, not advisory) ===
117
+
118
+ # Rule 1: unknowns exist → force ASK mode
119
+ if unknowns:
120
+ mode = "ask"
121
+ blocked_reason = f"Cannot act with {len(unknowns)} unknown(s). Resolve first."
122
+ warnings.append(f"UNKNOWNS: {', '.join(unknowns[:3])}")
123
+
124
+ # Rule 2: edit/execute without plan → force PROPOSE
125
+ if task_type in ("edit", "execute", "delegate") and not plan and mode == "act":
126
+ mode = "propose"
127
+ blocked_reason = "No plan defined for action task. Propose plan first."
128
+ warnings.append("MISSING PLAN: define steps before executing")
129
+
130
+ # Rule 3: edit/execute without verification → force PROPOSE
131
+ if task_type in ("edit", "execute") and not verification and mode == "act":
132
+ mode = "propose"
133
+ blocked_reason = "No verification step. How will you confirm it worked?"
134
+ warnings.append("MISSING VERIFICATION: define how to verify")
135
+
136
+ # Rule 4: execute without evidence → force PROPOSE
137
+ if task_type == "execute" and not evidence and mode == "act":
138
+ mode = "propose"
139
+ blocked_reason = "No evidence supporting this action."
140
+ warnings.append("MISSING EVIDENCE: what supports this action?")
141
+
142
+ # Rule 5: no goal → force ASK
143
+ if not goal:
144
+ mode = "ask"
145
+ blocked_reason = "No goal defined."
146
+ warnings.append("NO GOAL: what are you trying to achieve?")
147
+
148
+ # === TRUST-BASED ADJUSTMENTS ===
149
+ trust = _get_trust_score()
150
+ if trust < 30 and mode == "act" and task_type in ("edit", "execute"):
151
+ mode = "propose"
152
+ blocked_reason = f"Trust score {trust:.0f}/100 — propose before acting."
153
+ warnings.append(f"LOW TRUST ({trust:.0f}): extra verification required")
154
+
155
+ # === INJECT RELEVANT RULES ===
156
+ rules = _get_core_rules_for_task(task_type)
157
+
158
+ return {
159
+ "mode": mode,
160
+ "tools_available": _tools_for_mode(mode),
161
+ "warnings": warnings,
162
+ "blocked_reason": blocked_reason,
163
+ "injected_rules": rules,
164
+ "trust_score": round(trust),
165
+ }
166
+
167
+
168
+ def _tools_for_mode(mode: str) -> list[str]:
169
+ """Define which tool categories are available per mode."""
170
+ if mode == "ask":
171
+ return ["read", "search", "ask_user"]
172
+ elif mode == "propose":
173
+ return ["read", "search", "analyze", "propose_plan"]
174
+ else: # act
175
+ return ["all"]
176
+
177
+
178
+ def _parse_json_list(value) -> list:
179
+ try:
180
+ parsed = json.loads(value) if isinstance(value, str) else value
181
+ return parsed if isinstance(parsed, list) else []
182
+ except (json.JSONDecodeError, TypeError):
183
+ return []
184
+
185
+
186
+ def _parse_alternatives(value) -> list[dict]:
187
+ if isinstance(value, list):
188
+ raw_items = value
189
+ elif isinstance(value, str):
190
+ stripped = value.strip()
191
+ if not stripped:
192
+ return []
193
+ try:
194
+ parsed = json.loads(stripped)
195
+ except json.JSONDecodeError:
196
+ parsed = None
197
+ if isinstance(parsed, list):
198
+ raw_items = parsed
199
+ else:
200
+ lines = [line.strip("-* \t") for line in stripped.splitlines() if line.strip()]
201
+ raw_items = lines if lines else [item.strip() for item in stripped.split("|") if item.strip()]
202
+ else:
203
+ raw_items = [value]
204
+
205
+ normalized = []
206
+ for idx, item in enumerate(raw_items, start=1):
207
+ if isinstance(item, dict):
208
+ name = str(item.get("name") or item.get("title") or f"alternative_{idx}").strip()
209
+ description = str(item.get("description") or "").strip()
210
+ pros = item.get("pros") or []
211
+ cons = item.get("cons") or []
212
+ if isinstance(pros, str):
213
+ pros = [pros]
214
+ if isinstance(cons, str):
215
+ cons = [cons]
216
+ normalized.append({
217
+ "name": name,
218
+ "description": description,
219
+ "pros": [str(x).strip() for x in pros if str(x).strip()],
220
+ "cons": [str(x).strip() for x in cons if str(x).strip()],
221
+ })
222
+ continue
223
+ text = str(item).strip()
224
+ if not text:
225
+ continue
226
+ normalized.append({
227
+ "name": f"alternative_{idx}",
228
+ "description": text,
229
+ "pros": [],
230
+ "cons": [],
231
+ })
232
+ return normalized
233
+
234
+
235
+ def _tokenize(text: str, limit: int = 12) -> list[str]:
236
+ tokens = []
237
+ for token in re.findall(r"[a-z0-9_]{4,}", (text or "").lower()):
238
+ if token in STOP_WORDS:
239
+ continue
240
+ if token not in tokens:
241
+ tokens.append(token)
242
+ if len(tokens) >= limit:
243
+ break
244
+ return tokens
245
+
246
+
247
+ def _contains_any(text: str, terms: set[str]) -> bool:
248
+ lowered = (text or "").lower()
249
+ return any(term in lowered for term in terms)
250
+
251
+
252
+ def _impact_base(impact_level: str) -> float:
253
+ return {
254
+ "critical": 8.5,
255
+ "high": 7.0,
256
+ "medium": 5.5,
257
+ }.get((impact_level or "").lower(), 7.0)
258
+
259
+
260
+ def _constraint_penalty(text: str, constraints: list[str]) -> tuple[float, list[str]]:
261
+ penalty = 0.0
262
+ reasons: list[str] = []
263
+ lowered = (text or "").lower()
264
+ for constraint in constraints[:8]:
265
+ item = (constraint or "").strip()
266
+ lowered_constraint = item.lower()
267
+ if not item:
268
+ continue
269
+ if any(marker in lowered_constraint for marker in ("no ", "never", "must not", "do not", "without")):
270
+ tokens = _tokenize(lowered_constraint, limit=4)
271
+ if tokens and any(token in lowered for token in tokens):
272
+ penalty += 1.5
273
+ reasons.append(f"rozando constraint: {item[:80]}")
274
+ return penalty, reasons[:2]
275
+
276
+
277
+ def _history_signal(text: str, *, area: str = "", goal: str = "") -> dict:
278
+ conn = _get_db()
279
+ tokens = _tokenize(" ".join(part for part in [text, area, goal] if part), limit=6)
280
+ if not tokens:
281
+ return {"positive": 0.0, "negative": 0.0, "matched_decisions": 0, "matched_outcomes": 0}
282
+
283
+ decision_positive = 0
284
+ decision_negative = 0
285
+ matched_decisions = 0
286
+ for token in tokens[:3]:
287
+ rows = conn.execute(
288
+ """SELECT outcome FROM decisions
289
+ WHERE lower(decision) LIKE ? OR lower(alternatives) LIKE ? OR lower(based_on) LIKE ?
290
+ ORDER BY created_at DESC LIMIT 6""",
291
+ tuple(f"%{token}%" for _ in range(3)),
292
+ ).fetchall()
293
+ for row in rows:
294
+ matched_decisions += 1
295
+ outcome = (row["outcome"] or "").lower()
296
+ if _contains_any(outcome, NEGATIVE_OUTCOME_TERMS):
297
+ decision_negative += 1
298
+ elif _contains_any(outcome, POSITIVE_OUTCOME_TERMS):
299
+ decision_positive += 1
300
+
301
+ outcome_positive = 0
302
+ outcome_negative = 0
303
+ matched_outcomes = 0
304
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='outcomes'").fetchone():
305
+ for token in tokens[:3]:
306
+ rows = conn.execute(
307
+ """SELECT status FROM outcomes
308
+ WHERE lower(description) LIKE ? OR lower(expected_result) LIKE ? OR lower(action_type) LIKE ?
309
+ ORDER BY created_at DESC LIMIT 6""",
310
+ tuple(f"%{token}%" for _ in range(3)),
311
+ ).fetchall()
312
+ for row in rows:
313
+ matched_outcomes += 1
314
+ status = (row["status"] or "").lower()
315
+ if status == "met":
316
+ outcome_positive += 1
317
+ elif status in {"missed", "expired"}:
318
+ outcome_negative += 1
319
+
320
+ return {
321
+ "positive": min(2.5, (decision_positive * 0.4) + (outcome_positive * 0.5)),
322
+ "negative": min(3.0, (decision_negative * 0.6) + (outcome_negative * 0.7)),
323
+ "matched_decisions": matched_decisions,
324
+ "matched_outcomes": matched_outcomes,
325
+ }
326
+
327
+
328
+ def _historical_outcome_signal(
329
+ choice_name: str,
330
+ *,
331
+ area: str = "",
332
+ task_type: str = "",
333
+ goal_profile_id: str = "",
334
+ ) -> dict:
335
+ conn = _get_db()
336
+ clean_choice = (choice_name or "").strip().lower()
337
+ if not clean_choice:
338
+ return {
339
+ "active": False,
340
+ "threshold": HISTORICAL_OUTCOME_MIN_RESOLVED,
341
+ "resolved_outcomes": 0,
342
+ "met": 0,
343
+ "missed": 0,
344
+ "success_rate": None,
345
+ "success_adjustment": 0.0,
346
+ "risk_adjustment": 0.0,
347
+ }
348
+
349
+ has_eval = conn.execute(
350
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_evaluations'"
351
+ ).fetchone()
352
+ has_outcomes = conn.execute(
353
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='outcomes'"
354
+ ).fetchone()
355
+ if not has_eval or not has_outcomes:
356
+ return {
357
+ "active": False,
358
+ "threshold": HISTORICAL_OUTCOME_MIN_RESOLVED,
359
+ "resolved_outcomes": 0,
360
+ "met": 0,
361
+ "missed": 0,
362
+ "success_rate": None,
363
+ "success_adjustment": 0.0,
364
+ "risk_adjustment": 0.0,
365
+ }
366
+
367
+ clauses = [
368
+ "lower(e.selected_choice) = ?",
369
+ "o.status IN ('met', 'missed')",
370
+ ]
371
+ params: list[object] = [clean_choice]
372
+ if (area or "").strip():
373
+ clauses.append("e.area = ?")
374
+ params.append(area.strip())
375
+ if (task_type or "").strip():
376
+ clauses.append("e.task_type = ?")
377
+ params.append(task_type.strip())
378
+ if (goal_profile_id or "").strip():
379
+ clauses.append("e.goal_profile_id = ?")
380
+ params.append(goal_profile_id.strip())
381
+
382
+ rows = conn.execute(
383
+ f"""SELECT o.status
384
+ FROM cortex_evaluations e
385
+ JOIN outcomes o ON o.id = e.linked_outcome_id
386
+ WHERE {' AND '.join(clauses)}
387
+ ORDER BY e.created_at DESC, e.id DESC
388
+ LIMIT ?""",
389
+ params + [HISTORICAL_OUTCOME_LOOKBACK],
390
+ ).fetchall()
391
+ met = sum(1 for row in rows if (row["status"] or "").lower() == "met")
392
+ missed = sum(1 for row in rows if (row["status"] or "").lower() == "missed")
393
+ resolved = met + missed
394
+ active = resolved >= HISTORICAL_OUTCOME_MIN_RESOLVED
395
+ success_rate = round(met / resolved, 3) if resolved else None
396
+
397
+ success_adjustment = 0.0
398
+ risk_adjustment = 0.0
399
+ if active and success_rate is not None:
400
+ centered = success_rate - 0.5
401
+ success_adjustment = round(centered * 5.0, 2)
402
+ risk_adjustment = round((0.5 - success_rate) * 3.6, 2)
403
+
404
+ return {
405
+ "active": active,
406
+ "threshold": HISTORICAL_OUTCOME_MIN_RESOLVED,
407
+ "resolved_outcomes": resolved,
408
+ "met": met,
409
+ "missed": missed,
410
+ "success_rate": success_rate,
411
+ "success_adjustment": success_adjustment,
412
+ "risk_adjustment": risk_adjustment,
413
+ }
414
+
415
+
416
+ def _pattern_learning_signal(
417
+ choice_name: str,
418
+ *,
419
+ area: str = "",
420
+ task_type: str = "",
421
+ goal_profile_id: str = "",
422
+ ) -> dict:
423
+ clean_choice = (choice_name or "").strip()
424
+ if not clean_choice:
425
+ return {
426
+ "active": False,
427
+ "pattern_key": "",
428
+ "learning_id": 0,
429
+ "mode": "",
430
+ "title": "",
431
+ "success_adjustment": 0.0,
432
+ "risk_adjustment": 0.0,
433
+ }
434
+
435
+ try:
436
+ from db._outcomes import get_outcome_pattern_learning_signal
437
+ except Exception:
438
+ return {
439
+ "active": False,
440
+ "pattern_key": "",
441
+ "learning_id": 0,
442
+ "mode": "",
443
+ "title": "",
444
+ "success_adjustment": 0.0,
445
+ "risk_adjustment": 0.0,
446
+ }
447
+
448
+ return get_outcome_pattern_learning_signal(
449
+ area=area,
450
+ task_type=task_type,
451
+ goal_profile_id=goal_profile_id,
452
+ selected_choice=clean_choice,
453
+ )
454
+
455
+
456
+ def _somatic_penalty(*parts: str) -> float:
457
+ conn = _get_db()
458
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='somatic_events'").fetchone():
459
+ return 0.0
460
+
461
+ query_terms = [token for token in _tokenize(" ".join(parts), limit=4) if token]
462
+ if not query_terms:
463
+ return 0.0
464
+
465
+ penalty = 0.0
466
+ for term in query_terms[:3]:
467
+ rows = conn.execute(
468
+ """SELECT delta FROM somatic_events
469
+ WHERE projected = 0 AND lower(target) LIKE ?
470
+ ORDER BY timestamp DESC LIMIT 8""",
471
+ (f"%{term}%",),
472
+ ).fetchall()
473
+ for row in rows:
474
+ delta = float(row["delta"] or 0.0)
475
+ if delta < 0:
476
+ penalty += abs(delta)
477
+ return round(min(5.0, penalty), 2)
478
+
479
+
480
+ def _resolve_linked_outcome_id(*, linked_outcome_id: int | str | None = None, task_id: str = "") -> int | None:
481
+ try:
482
+ explicit = int(linked_outcome_id or 0)
483
+ except (TypeError, ValueError):
484
+ explicit = 0
485
+ if explicit > 0:
486
+ return explicit
487
+
488
+ clean_task_id = (task_id or "").strip()
489
+ if not clean_task_id:
490
+ return None
491
+
492
+ conn = _get_db()
493
+ if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='outcomes'").fetchone():
494
+ return None
495
+
496
+ row = conn.execute(
497
+ """SELECT id FROM outcomes
498
+ WHERE action_id = ? AND status = 'pending'
499
+ ORDER BY
500
+ CASE metric_source
501
+ WHEN 'protocol_task_status' THEN 0
502
+ WHEN 'decision_outcome' THEN 1
503
+ ELSE 2
504
+ END,
505
+ deadline ASC,
506
+ created_at DESC
507
+ LIMIT 1""",
508
+ (clean_task_id,),
509
+ ).fetchone()
510
+ return int(row["id"]) if row else None
511
+
512
+
513
+ def _score_alternative(
514
+ alternative: dict,
515
+ *,
516
+ goal: str,
517
+ area: str,
518
+ task_type: str,
519
+ impact_level: str,
520
+ constraints: list[str],
521
+ evidence_refs: list[str],
522
+ goal_profile: dict,
523
+ ) -> dict:
524
+ text = " ".join([
525
+ alternative.get("name", ""),
526
+ alternative.get("description", ""),
527
+ " ".join(alternative.get("pros") or []),
528
+ " ".join(alternative.get("cons") or []),
529
+ ]).strip()
530
+ lowered = text.lower()
531
+ impact = _impact_base(impact_level)
532
+ success = 5.0 + min(2.0, len(evidence_refs) * 0.4)
533
+ risk = 2.5
534
+ reasons: list[str] = []
535
+ weights = goal_profile.get("weights") or {}
536
+ direct_hits = _term_hits(lowered, DIRECT_IMPACT_TERMS)
537
+ safe_hits = _term_hits(lowered, SAFE_TERMS)
538
+ risk_hits = _term_hits(lowered, RISK_TERMS)
539
+ focus = max(weights, key=weights.get) if weights else "impact"
540
+
541
+ if direct_hits:
542
+ impact += min(1.6, direct_hits * 0.4)
543
+ reasons.append("apunta directo al objetivo")
544
+ if safe_hits:
545
+ success += min(1.8, safe_hits * 0.45)
546
+ risk = max(1.0, risk - min(1.1, safe_hits * 0.35))
547
+ reasons.append("incluye verificación o despliegue seguro")
548
+ if not safe_hits and task_type in {"edit", "execute"}:
549
+ risk += 1.2
550
+ reasons.append("no explicita verificación")
551
+ if risk_hits:
552
+ risk += min(2.8, risk_hits * 0.7)
553
+ reasons.append("contiene señales de alto riesgo")
554
+
555
+ if focus == "impact" and direct_hits:
556
+ impact += 0.45
557
+ risk = max(1.0, risk - 0.35)
558
+ reasons.append("el perfil activo prioriza impacto")
559
+ elif focus == "impact":
560
+ impact = max(1.0, impact - 0.35)
561
+ reasons.append("el perfil activo penaliza opciones de bajo empuje")
562
+ elif focus == "success" and safe_hits:
563
+ success += 0.45
564
+ reasons.append("el perfil activo prioriza exito verificable")
565
+ elif focus == "risk":
566
+ if safe_hits:
567
+ risk = max(1.0, risk - 0.4)
568
+ if risk_hits:
569
+ risk += 0.8
570
+ reasons.append("el perfil activo penaliza riesgo")
571
+ elif focus == "somatic":
572
+ reasons.append("el perfil activo da peso a la huella somática")
573
+
574
+ history = _history_signal(lowered, area=area, goal=goal)
575
+ success += history["positive"]
576
+ risk += history["negative"]
577
+ if history["positive"]:
578
+ reasons.append("histórico parecido favorable")
579
+ if history["negative"]:
580
+ reasons.append("histórico parecido conflictivo")
581
+
582
+ historical = _historical_outcome_signal(
583
+ alternative.get("name", ""),
584
+ area=area,
585
+ task_type=task_type,
586
+ goal_profile_id=(goal_profile.get("profile_id") or ""),
587
+ )
588
+ if historical["active"]:
589
+ success += historical["success_adjustment"]
590
+ risk += historical["risk_adjustment"]
591
+ if historical["success_adjustment"] > 0:
592
+ reasons.append(
593
+ f"histórico resuelto favorable ({historical['met']}/{historical['resolved_outcomes']} met)"
594
+ )
595
+ elif historical["success_adjustment"] < 0:
596
+ reasons.append(
597
+ f"histórico resuelto flojo ({historical['missed']}/{historical['resolved_outcomes']} missed)"
598
+ )
599
+ elif historical["resolved_outcomes"] > 0:
600
+ reasons.append(
601
+ f"histórico insuficiente aún ({historical['resolved_outcomes']}/{historical['threshold']} outcomes)"
602
+ )
603
+
604
+ pattern_learning = _pattern_learning_signal(
605
+ alternative.get("name", ""),
606
+ area=area,
607
+ task_type=task_type,
608
+ goal_profile_id=(goal_profile.get("profile_id") or ""),
609
+ )
610
+ if pattern_learning["active"]:
611
+ success += pattern_learning["success_adjustment"]
612
+ risk += pattern_learning["risk_adjustment"]
613
+ if pattern_learning["mode"] == "prefer":
614
+ reasons.append("regla estructurada capturada favorece esta estrategia")
615
+ elif pattern_learning["mode"] == "avoid":
616
+ reasons.append("regla estructurada capturada penaliza esta estrategia")
617
+
618
+ constraint_penalty, constraint_reasons = _constraint_penalty(lowered, constraints)
619
+ if constraint_penalty:
620
+ risk += constraint_penalty
621
+ reasons.extend(constraint_reasons)
622
+
623
+ somatic = _somatic_penalty(area, goal, lowered)
624
+ total = round(
625
+ (impact * float(weights.get("impact", 0.35)))
626
+ + (success * float(weights.get("success", 0.30)))
627
+ - (risk * float(weights.get("risk", 0.20)))
628
+ - (somatic * float(weights.get("somatic", 0.15))),
629
+ 3,
630
+ )
631
+ return {
632
+ "name": alternative.get("name", ""),
633
+ "impact": round(max(1.0, min(10.0, impact)), 2),
634
+ "success_probability": round(max(1.0, min(10.0, success)), 2),
635
+ "risk_level": round(max(1.0, min(10.0, risk)), 2),
636
+ "somatic_penalty": round(max(0.0, min(5.0, somatic)), 2),
637
+ "total_score": total,
638
+ "notes": reasons[:4],
639
+ "goal_profile_focus": focus,
640
+ "history_matches": {
641
+ "decisions": history["matched_decisions"],
642
+ "outcomes": history["matched_outcomes"],
643
+ },
644
+ "historical_signal": historical,
645
+ "pattern_learning_signal": pattern_learning,
646
+ }
647
+
648
+
649
+ def evaluate_cortex_state(state: dict) -> dict:
650
+ """Return structured Cortex evaluation for internal callers."""
651
+ result = _validate_state(state)
652
+ result["check_id"] = f"CTX-{int(time.time())}-{secrets.randbelow(100000)}"
653
+ result["expires_at_epoch"] = int(time.time()) + 1200
654
+ return result
655
+
656
+
657
+ def _log_cortex_activation(goal: str, task_type: str, result: dict):
658
+ try:
659
+ conn = _get_db()
660
+ conn.execute(
661
+ """CREATE TABLE IF NOT EXISTS cortex_log (
662
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
663
+ goal TEXT,
664
+ task_type TEXT,
665
+ mode TEXT,
666
+ warnings TEXT,
667
+ trust_score INTEGER,
668
+ created_at TEXT DEFAULT (datetime('now'))
669
+ )"""
670
+ )
671
+ conn.execute(
672
+ "INSERT INTO cortex_log (goal, task_type, mode, warnings, trust_score) VALUES (?, ?, ?, ?, ?)",
673
+ (
674
+ goal[:200],
675
+ task_type,
676
+ result["mode"],
677
+ json.dumps(result["warnings"]),
678
+ result["trust_score"],
679
+ ),
680
+ )
681
+ conn.commit()
682
+ except Exception:
683
+ pass
684
+
685
+
686
+ def _format_decision_summary(recommended: dict, alternatives_scored: list[dict]) -> str:
687
+ notes = ", ".join(recommended.get("notes") or []) or "balance general más sólido"
688
+ historical = recommended.get("historical_signal") or {}
689
+ second_gap = 0.0
690
+ if len(alternatives_scored) > 1:
691
+ second_gap = recommended["total_score"] - alternatives_scored[1]["total_score"]
692
+ if historical.get("active"):
693
+ notes = (
694
+ f"{notes}; histórico resuelto {historical.get('met', 0)}/"
695
+ f"{historical.get('resolved_outcomes', 0)} favorable en contexto comparable"
696
+ )
697
+ if second_gap > 0.2:
698
+ return f"Recomendada por margen claro ({second_gap:.2f}) y porque {notes}."
699
+ return f"Recomendada por el mejor balance entre impacto, éxito, riesgo y huella somática; {notes}."
700
+
701
+
702
+ def handle_cortex_check(
703
+ goal: str,
704
+ task_type: str = "answer",
705
+ plan: str = "[]",
706
+ known_facts: str = "[]",
707
+ unknowns: str = "[]",
708
+ constraints: str = "[]",
709
+ evidence_refs: str = "[]",
710
+ verification_step: str = "",
711
+ ) -> str:
712
+ """Cognitive Cortex pre-action check. Call BEFORE significant actions.
713
+
714
+ Validates your reasoning state and determines if you can act, should propose,
715
+ or need to ask for clarification first. Implements architectural inhibitory control.
716
+
717
+ WHEN TO CALL:
718
+ - Before editing files or running commands
719
+ - Before delegating to subagents
720
+ - When the task has multiple possible approaches
721
+ - After a failed attempt (before retrying)
722
+ - When user instruction seems to conflict with known facts
723
+
724
+ DO NOT CALL for simple chat responses, greetings, or explanations.
725
+
726
+ Args:
727
+ goal: What you are trying to achieve (required)
728
+ task_type: One of: answer, analyze, edit, execute, delegate
729
+ plan: JSON array of planned steps (e.g. '["read file", "edit function", "test"]')
730
+ known_facts: JSON array of facts you have (from user, memory, files)
731
+ unknowns: JSON array of things you don't know yet but need
732
+ constraints: JSON array of rules or limitations that apply
733
+ evidence_refs: JSON array of evidence supporting your plan (learnings, user statements, file contents)
734
+ verification_step: How you will verify the action worked
735
+
736
+ Returns:
737
+ Mode (ask/propose/act), available tools, warnings, and relevant Core Rules
738
+ """
739
+ try:
740
+ clean_type = validate_task_type(task_type)
741
+ except ValueError as exc:
742
+ return "\n".join(
743
+ [
744
+ f"ERROR: {exc}",
745
+ f"Valid task types: {', '.join(sorted(VALID_TASK_TYPES))}",
746
+ ]
747
+ )
748
+
749
+ state = {
750
+ "goal": goal.strip() if goal else "",
751
+ "task_type": clean_type,
752
+ "plan": _parse_json_list(plan),
753
+ "known_facts": _parse_json_list(known_facts),
754
+ "unknowns": _parse_json_list(unknowns),
755
+ "constraints": _parse_json_list(constraints),
756
+ "evidence_refs": _parse_json_list(evidence_refs),
757
+ "verification_step": verification_step.strip() if verification_step else "",
758
+ }
759
+
760
+ result = evaluate_cortex_state(state)
761
+
762
+ # Format response
763
+ lines = [
764
+ f"CORTEX CHECK — mode: {result['mode'].upper()}",
765
+ f"Trust: {result['trust_score']}/100",
766
+ f"Check ID: {result['check_id']}",
767
+ f"Valid until epoch: {result['expires_at_epoch']}",
768
+ ]
769
+
770
+ if result["mode"] == "act":
771
+ lines.append("CLEARED: You may proceed with the action.")
772
+ elif result["mode"] == "propose":
773
+ lines.append(f"PROPOSE ONLY: {result['blocked_reason']}")
774
+ lines.append("Show the user your plan and get approval before executing.")
775
+ elif result["mode"] == "ask":
776
+ lines.append(f"ASK FIRST: {result['blocked_reason']}")
777
+ lines.append("Gather the missing information before proceeding.")
778
+
779
+ if result["warnings"]:
780
+ lines.append("")
781
+ lines.append("Warnings:")
782
+ for w in result["warnings"]:
783
+ lines.append(f" - {w}")
784
+
785
+ if result["injected_rules"]:
786
+ lines.append("")
787
+ lines.append("Applicable Core Rules:")
788
+ for r in result["injected_rules"]:
789
+ lines.append(f" - {r}")
790
+
791
+ lines.append("")
792
+ lines.append(f"Tools available: {', '.join(result['tools_available'])}")
793
+
794
+ _log_cortex_activation(goal, task_type, result)
795
+
796
+ return "\n".join(lines)
797
+
798
+
799
+ def handle_cortex_stats(days: int = 7) -> str:
800
+ """View Cortex activation statistics — how often it activates, modes, warnings.
801
+
802
+ Args:
803
+ days: Period to analyze (default 7)
804
+ """
805
+ conn = _get_db()
806
+ try:
807
+ conn.execute("SELECT 1 FROM cortex_log LIMIT 1")
808
+ except Exception:
809
+ return "No Cortex data yet. The Cortex activates on significant actions."
810
+
811
+ cutoff = f"datetime('now', '-{days} days')"
812
+
813
+ total = conn.execute(f"SELECT COUNT(*) FROM cortex_log WHERE created_at >= {cutoff}").fetchone()[0]
814
+ by_mode = conn.execute(
815
+ f"SELECT mode, COUNT(*) as c FROM cortex_log WHERE created_at >= {cutoff} GROUP BY mode ORDER BY c DESC"
816
+ ).fetchall()
817
+ by_type = conn.execute(
818
+ f"SELECT task_type, COUNT(*) as c FROM cortex_log WHERE created_at >= {cutoff} GROUP BY task_type ORDER BY c DESC"
819
+ ).fetchall()
820
+
821
+ lines = [
822
+ f"CORTEX STATS — last {days} days",
823
+ f"Total activations: {total}",
824
+ "",
825
+ "By mode:",
826
+ ]
827
+ for r in by_mode:
828
+ pct = (r["c"] / total * 100) if total > 0 else 0
829
+ lines.append(f" {r['mode']}: {r['c']} ({pct:.0f}%)")
830
+
831
+ lines.append("")
832
+ lines.append("By task type:")
833
+ for r in by_type:
834
+ lines.append(f" {r['task_type']}: {r['c']}")
835
+
836
+ # Inhibition rate = % of activations that resulted in ask or propose (not act)
837
+ inhibited = sum(r["c"] for r in by_mode if r["mode"] != "act")
838
+ inhibition_rate = (inhibited / total * 100) if total > 0 else 0
839
+ lines.append(f"\nInhibition rate: {inhibition_rate:.0f}% (target: 30-60%)")
840
+
841
+ return "\n".join(lines)
842
+
843
+
844
+ def handle_cortex_decide(
845
+ goal: str,
846
+ alternatives: str,
847
+ task_type: str = "execute",
848
+ impact_level: str = "high",
849
+ context_hint: str = "",
850
+ area: str = "",
851
+ constraints: str = "[]",
852
+ evidence_refs: str = "[]",
853
+ session_id: str = "",
854
+ task_id: str = "",
855
+ linked_outcome_id: int = 0,
856
+ goal_profile_id: str = "",
857
+ goal_id: str = "",
858
+ ) -> str:
859
+ """Evaluate concrete alternatives for a high-impact task using the existing Cortex."""
860
+ clean_goal = (goal or "").strip()
861
+ if not clean_goal:
862
+ return json.dumps({"ok": False, "error": "goal is required"}, ensure_ascii=False, indent=2)
863
+
864
+ parsed_alternatives = _parse_alternatives(alternatives)
865
+ if len(parsed_alternatives) < 2:
866
+ return json.dumps(
867
+ {
868
+ "ok": False,
869
+ "error": "Provide at least 2 alternatives so the Cortex can rank tradeoffs.",
870
+ },
871
+ ensure_ascii=False,
872
+ indent=2,
873
+ )
874
+
875
+ try:
876
+ clean_type = validate_task_type(task_type)
877
+ except ValueError as exc:
878
+ return json.dumps(
879
+ {
880
+ "ok": False,
881
+ "error": str(exc),
882
+ "valid_task_types": sorted(VALID_TASK_TYPES),
883
+ },
884
+ ensure_ascii=False,
885
+ indent=2,
886
+ )
887
+ try:
888
+ clean_level = validate_impact_level(impact_level)
889
+ except ValueError as exc:
890
+ return json.dumps(
891
+ {
892
+ "ok": False,
893
+ "error": str(exc),
894
+ "valid_impact_levels": sorted(VALID_IMPACT_LEVELS),
895
+ },
896
+ ensure_ascii=False,
897
+ indent=2,
898
+ )
899
+ parsed_constraints = _parse_json_list(constraints)
900
+ parsed_evidence = _parse_json_list(evidence_refs)
901
+ try:
902
+ from db import resolve_goal_profile
903
+
904
+ resolved_goal_profile = resolve_goal_profile(
905
+ profile_id=goal_profile_id,
906
+ area=area.strip(),
907
+ task_type=clean_type,
908
+ goal_id=goal_id,
909
+ )
910
+ except Exception as exc:
911
+ return json.dumps({"ok": False, "error": f"Failed to resolve goal profile: {exc}"}, ensure_ascii=False, indent=2)
912
+
913
+ scored = [
914
+ _score_alternative(
915
+ item,
916
+ goal=clean_goal,
917
+ area=area.strip(),
918
+ task_type=clean_type,
919
+ impact_level=clean_level,
920
+ constraints=parsed_constraints,
921
+ evidence_refs=parsed_evidence,
922
+ goal_profile=resolved_goal_profile,
923
+ )
924
+ for item in parsed_alternatives
925
+ ]
926
+ scored.sort(key=lambda item: item["total_score"], reverse=True)
927
+ recommended = scored[0]
928
+ reasoning = _format_decision_summary(recommended, scored)
929
+ resolved_outcome_id = _resolve_linked_outcome_id(
930
+ linked_outcome_id=linked_outcome_id,
931
+ task_id=task_id,
932
+ )
933
+
934
+ # Auto-create outcome when none exists, so cortex decisions
935
+ # get verified by outcome-checker and close the feedback loop.
936
+ if resolved_outcome_id is None and clean_goal and task_id:
937
+ try:
938
+ from db import create_outcome
939
+
940
+ _deadline = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
941
+ _outcome = create_outcome(
942
+ action_type="cortex_decision",
943
+ description=f"Cortex decision: {clean_goal[:120]}",
944
+ expected_result=f"Recommended '{scored[0]['name']}' succeeds",
945
+ metric_source="decision_outcome",
946
+ action_id=task_id,
947
+ session_id=session_id,
948
+ deadline=_deadline,
949
+ )
950
+ if isinstance(_outcome, dict) and _outcome.get("id"):
951
+ resolved_outcome_id = int(_outcome["id"])
952
+ except Exception:
953
+ pass # non-critical: decision still records without outcome
954
+
955
+ try:
956
+ from db import create_cortex_evaluation
957
+
958
+ record = create_cortex_evaluation(
959
+ session_id=session_id,
960
+ task_id=task_id,
961
+ goal=clean_goal,
962
+ task_type=clean_type,
963
+ area=area,
964
+ impact_level=clean_level,
965
+ context_hint=context_hint,
966
+ alternatives=parsed_alternatives,
967
+ scores=scored,
968
+ recommended_choice=recommended["name"],
969
+ recommended_reasoning=reasoning,
970
+ linked_outcome_id=resolved_outcome_id,
971
+ goal_profile_id=resolved_goal_profile.get("profile_id", ""),
972
+ goal_profile_labels=resolved_goal_profile.get("goal_labels", []),
973
+ goal_profile_weights=resolved_goal_profile.get("weights", {}),
974
+ selected_choice=recommended["name"],
975
+ selection_reason=reasoning,
976
+ selection_source="recommended",
977
+ )
978
+ except Exception as exc:
979
+ return json.dumps(
980
+ {
981
+ "ok": False,
982
+ "error": f"Failed to persist cortex evaluation: {exc}",
983
+ },
984
+ ensure_ascii=False,
985
+ indent=2,
986
+ )
987
+
988
+ return json.dumps(
989
+ {
990
+ "ok": True,
991
+ "evaluation_id": record.get("id"),
992
+ "task_id": task_id,
993
+ "goal": clean_goal,
994
+ "impact_level": clean_level,
995
+ "recommendation": recommended["name"],
996
+ "reasoning": reasoning,
997
+ "selected_choice": record.get("selected_choice"),
998
+ "selection_source": record.get("selection_source"),
999
+ "linked_outcome_id": record.get("linked_outcome_id"),
1000
+ "goal_profile": {
1001
+ "profile_id": resolved_goal_profile.get("profile_id", ""),
1002
+ "profile_name": resolved_goal_profile.get("profile_name", ""),
1003
+ "resolved_by": resolved_goal_profile.get("resolved_by", ""),
1004
+ "goal_labels": resolved_goal_profile.get("goal_labels", []),
1005
+ "weights": resolved_goal_profile.get("weights", {}),
1006
+ },
1007
+ "alternatives": parsed_alternatives,
1008
+ "scores": scored,
1009
+ "next_action": "Apply the recommended choice or call nexo_cortex_override if you intentionally choose another option.",
1010
+ },
1011
+ ensure_ascii=False,
1012
+ indent=2,
1013
+ )
1014
+
1015
+
1016
+ def handle_cortex_review(evaluation_id: int = 0, task_id: str = "", session_id: str = "", limit: int = 10) -> str:
1017
+ """Review stored Cortex alternative evaluations."""
1018
+ from db import get_cortex_evaluation, list_cortex_evaluations
1019
+
1020
+ if evaluation_id:
1021
+ item = get_cortex_evaluation(evaluation_id)
1022
+ if not item:
1023
+ return json.dumps({"ok": False, "error": f"Unknown evaluation_id: {evaluation_id}"}, ensure_ascii=False, indent=2)
1024
+ return json.dumps({"ok": True, "evaluation": item}, ensure_ascii=False, indent=2)
1025
+
1026
+ items = list_cortex_evaluations(session_id=session_id, task_id=task_id, limit=limit)
1027
+ return json.dumps({"ok": True, "evaluations": items}, ensure_ascii=False, indent=2)
1028
+
1029
+
1030
+ def handle_cortex_override(evaluation_id: int, chosen: str, reason: str) -> str:
1031
+ """Override the Cortex recommendation while leaving the recommendation trail intact."""
1032
+ if not chosen.strip():
1033
+ return json.dumps({"ok": False, "error": "chosen is required"}, ensure_ascii=False, indent=2)
1034
+ if not reason.strip():
1035
+ return json.dumps({"ok": False, "error": "reason is required"}, ensure_ascii=False, indent=2)
1036
+
1037
+ from db import get_cortex_evaluation, override_cortex_evaluation
1038
+
1039
+ current = get_cortex_evaluation(evaluation_id)
1040
+ if not current:
1041
+ return json.dumps({"ok": False, "error": f"Unknown evaluation_id: {evaluation_id}"}, ensure_ascii=False, indent=2)
1042
+
1043
+ alternatives = _parse_json_list(current.get("alternatives") or "[]")
1044
+ valid_names = {str(item.get("name", "")).strip() for item in alternatives if isinstance(item, dict)}
1045
+ if chosen.strip() not in valid_names:
1046
+ return json.dumps(
1047
+ {
1048
+ "ok": False,
1049
+ "error": "chosen must match one of the stored alternative names",
1050
+ "valid_choices": sorted(valid_names),
1051
+ },
1052
+ ensure_ascii=False,
1053
+ indent=2,
1054
+ )
1055
+
1056
+ updated = override_cortex_evaluation(
1057
+ evaluation_id,
1058
+ selected_choice=chosen,
1059
+ selection_reason=reason,
1060
+ )
1061
+ return json.dumps({"ok": True, "evaluation": updated}, ensure_ascii=False, indent=2)
1062
+
1063
+
1064
+ # v5.2.0: Cortex quality cache reader. The `nexo-cortex-cycle` cron
1065
+ # (src/scripts/nexo-cortex-cycle.py) writes a fresh quality snapshot to
1066
+ # $NEXO_HOME/operations/cortex-quality-latest.json every 6h. Until this
1067
+ # release the reader was missing — the snapshot was write-only and every
1068
+ # call to `nexo_cortex_quality` re-ran the SQL summary. Now the handler
1069
+ # reads the cache first for the 7d / 1d windows and falls back silently
1070
+ # to the live computation on any failure.
1071
+ _CORTEX_QUALITY_CACHE_PATH = (
1072
+ Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
1073
+ / "operations"
1074
+ / "cortex-quality-latest.json"
1075
+ )
1076
+ # 6h cron + 30 min slack so a slightly-late run still serves cache.
1077
+ _CORTEX_QUALITY_CACHE_MAX_AGE_SECONDS = 23400
1078
+ _CORTEX_QUALITY_CACHE_WINDOWS = {1: "window_1d", 7: "window_7d"}
1079
+ _CORTEX_QUALITY_CACHE_SCHEMA = 1
1080
+
1081
+
1082
+ def _load_cortex_quality_cache(days: int) -> dict | None:
1083
+ """Return cached summary dict for the requested window, or None if unusable.
1084
+
1085
+ Silent on any failure so the live path always wins on a corrupt cache.
1086
+ Respects the snapshot schema written by `_persist_quality_snapshot`
1087
+ in src/scripts/nexo-cortex-cycle.py — do NOT change the layout here
1088
+ without updating the writer in the same release.
1089
+ """
1090
+ window_key = _CORTEX_QUALITY_CACHE_WINDOWS.get(days)
1091
+ if window_key is None:
1092
+ return None
1093
+ try:
1094
+ if not _CORTEX_QUALITY_CACHE_PATH.is_file():
1095
+ return None
1096
+ payload = json.loads(
1097
+ _CORTEX_QUALITY_CACHE_PATH.read_text(encoding="utf-8")
1098
+ )
1099
+ except Exception:
1100
+ return None
1101
+ if not isinstance(payload, dict):
1102
+ return None
1103
+ if payload.get("schema") != _CORTEX_QUALITY_CACHE_SCHEMA:
1104
+ return None
1105
+ captured_at = payload.get("captured_at") or ""
1106
+ if not isinstance(captured_at, str):
1107
+ return None
1108
+ try:
1109
+ captured = datetime.fromisoformat(captured_at)
1110
+ except Exception:
1111
+ return None
1112
+ age = time.time() - captured.timestamp()
1113
+ if age < 0 or age > _CORTEX_QUALITY_CACHE_MAX_AGE_SECONDS:
1114
+ return None
1115
+ window = payload.get(window_key)
1116
+ if not isinstance(window, dict):
1117
+ return None
1118
+ return window
1119
+
1120
+
1121
+ def handle_cortex_quality(days: int = 30) -> str:
1122
+ """Summarise recommendation quality, overrides, and linked outcome results.
1123
+
1124
+ v5.2.0: Serves the snapshot written by `nexo-cortex-cycle` when the
1125
+ requested window is 7 or 1 days and the snapshot is fresh
1126
+ (< 6h30m old, schema == 1). Falls back silently to a live SQL
1127
+ summary on any failure, so the caller always gets a valid response.
1128
+ The returned JSON includes `"source": "cache" | "live"` so the
1129
+ path taken is observable from the outside.
1130
+ """
1131
+ from db import cortex_evaluation_summary
1132
+
1133
+ cached = _load_cortex_quality_cache(days)
1134
+ if cached is not None:
1135
+ return json.dumps(
1136
+ {"ok": True, "summary": cached, "source": "cache"},
1137
+ ensure_ascii=False,
1138
+ indent=2,
1139
+ )
1140
+ summary = cortex_evaluation_summary(days=days)
1141
+ return json.dumps(
1142
+ {"ok": True, "summary": summary, "source": "live"},
1143
+ ensure_ascii=False,
1144
+ indent=2,
1145
+ )
1146
+
1147
+
1148
+ TOOLS = [
1149
+ (handle_cortex_check, "nexo_cortex_check", "Cognitive pre-action check. Validates reasoning and determines if you can act, should propose, or need to ask first. Call before significant actions."),
1150
+ (handle_cortex_decide, "nexo_cortex_decide", "Evaluate 2+ alternatives for a high-impact task and persist the recommendation on top of the existing Cortex."),
1151
+ (handle_cortex_review, "nexo_cortex_review", "Review persisted Cortex alternative evaluations by ID, task, or session."),
1152
+ (handle_cortex_override, "nexo_cortex_override", "Override a stored Cortex recommendation while preserving the recommendation trail."),
1153
+ (handle_cortex_quality, "nexo_cortex_quality", "Summarise recommendation accept rate, override rate, and linked outcome success for Cortex evaluations."),
1154
+ (handle_cortex_stats, "nexo_cortex_stats", "View Cortex activation statistics — modes, task types, inhibition rate."),
1155
+ ]