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,612 @@
1
+ """Learnings CRUD tools: add, search, update, delete, list."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+
6
+ from db import (create_learning, update_learning, delete_learning, search_learnings,
7
+ list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning)
8
+
9
+ NEGATION_PATTERNS = (
10
+ "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
11
+ "disable", "disabled", "remove", "ban", "bypass",
12
+ )
13
+ CONTRADICTION_PAIRS = (
14
+ ("enable", "disable"),
15
+ ("use", "avoid"),
16
+ ("add", "remove"),
17
+ ("allow", "forbid"),
18
+ ("always", "never"),
19
+ ("before", "after"),
20
+ ("require", "skip"),
21
+ ("validate", "skip"),
22
+ ("validate", "bypass"),
23
+ ("include", "exclude"),
24
+ )
25
+
26
+
27
+ def _split_applies_to(applies_to: str) -> list[str]:
28
+ return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
29
+
30
+
31
+ def _normalize_applies_token(value: str) -> str:
32
+ return str(value or "").replace("\\", "/").rstrip("/").lower()
33
+
34
+
35
+ def _applies_overlap(left: str, right: str) -> bool:
36
+ left_tokens = {_normalize_applies_token(item) for item in _split_applies_to(left)}
37
+ right_tokens = {_normalize_applies_token(item) for item in _split_applies_to(right)}
38
+ left_tokens.discard("")
39
+ right_tokens.discard("")
40
+ if not left_tokens or not right_tokens:
41
+ return False
42
+ if left_tokens & right_tokens:
43
+ return True
44
+ for left_token in left_tokens:
45
+ for right_token in right_tokens:
46
+ if "/" in left_token or "/" in right_token:
47
+ if left_token.startswith(f"{right_token}/") or right_token.startswith(f"{left_token}/"):
48
+ return True
49
+ if left_token.endswith(f"/{right_token}") or right_token.endswith(f"/{left_token}"):
50
+ return True
51
+ return False
52
+
53
+
54
+ def _normalize_text(text: str) -> str:
55
+ return re.sub(r"\s+", " ", str(text or "").strip().lower())
56
+
57
+
58
+ def _tokenize(text: str) -> list[str]:
59
+ return re.findall(r"[a-z0-9_-]+", _normalize_text(text))
60
+
61
+
62
+ def _contains_negation(text: str) -> bool:
63
+ lowered = _normalize_text(text)
64
+ return any(token in lowered for token in NEGATION_PATTERNS)
65
+
66
+
67
+ def _negated_action_verbs(text: str) -> set[str]:
68
+ lowered = _normalize_text(text)
69
+ matches = set()
70
+ for pattern in (
71
+ r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)",
72
+ r"(?:do not|don't)\s+([a-z0-9_-]+)",
73
+ ):
74
+ matches.update(re.findall(pattern, lowered))
75
+ return {match for match in matches if len(match) > 2}
76
+
77
+
78
+ def _looks_contradictory(existing_text: str, new_text: str) -> bool:
79
+ existing_norm = _normalize_text(existing_text)
80
+ new_norm = _normalize_text(new_text)
81
+ if not existing_norm or not new_norm:
82
+ return False
83
+ existing_tokens = set(_tokenize(existing_norm))
84
+ new_tokens = set(_tokenize(new_norm))
85
+ if not (existing_tokens & new_tokens):
86
+ return False
87
+ existing_negated_verbs = _negated_action_verbs(existing_norm)
88
+ new_negated_verbs = _negated_action_verbs(new_norm)
89
+ if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
90
+ return True
91
+ if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
92
+ return True
93
+ if _contains_negation(existing_norm) != _contains_negation(new_norm):
94
+ return True
95
+ for positive, negative in CONTRADICTION_PAIRS:
96
+ existing_has_pair = positive in existing_norm or negative in existing_norm
97
+ new_has_pair = positive in new_norm or negative in new_norm
98
+ if existing_has_pair and new_has_pair:
99
+ if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
100
+ return True
101
+ return False
102
+
103
+
104
+ def _find_conflicting_active_learning(conn, *, category: str, title: str, content: str,
105
+ applies_to: str, exclude_id: int | None = None) -> dict | None:
106
+ if not applies_to:
107
+ return None
108
+ params = [category]
109
+ sql = (
110
+ "SELECT id, title, content, applies_to FROM learnings "
111
+ "WHERE category = ? AND status = 'active' AND COALESCE(applies_to, '') != ''"
112
+ )
113
+ if exclude_id is not None:
114
+ sql += " AND id != ?"
115
+ params.append(exclude_id)
116
+ rows = conn.execute(sql, tuple(params)).fetchall()
117
+ incoming_text = f"{title} {content}"
118
+ for row in rows:
119
+ if not _applies_overlap(row["applies_to"], applies_to):
120
+ continue
121
+ if _looks_contradictory(f"{row['title']} {row['content']}", incoming_text):
122
+ return dict(row)
123
+ return None
124
+
125
+
126
+ def find_conflicting_active_learning(*, category: str, title: str, content: str,
127
+ applies_to: str, exclude_id: int | None = None) -> dict | None:
128
+ """Public wrapper for canonical-rule enforcement on file-scoped learnings."""
129
+ conn = get_db()
130
+ return _find_conflicting_active_learning(
131
+ conn,
132
+ category=category.lower().strip(),
133
+ title=title,
134
+ content=content,
135
+ applies_to=applies_to,
136
+ exclude_id=exclude_id,
137
+ )
138
+
139
+
140
+ def _priority_score(priority: str) -> float:
141
+ return {
142
+ "critical": 1.0,
143
+ "high": 0.85,
144
+ "medium": 0.65,
145
+ "low": 0.45,
146
+ }.get(str(priority or "medium").strip().lower(), 0.65)
147
+
148
+
149
+ def _parse_timestamp(value) -> float:
150
+ if isinstance(value, (int, float)):
151
+ return float(value)
152
+ text = str(value or "").strip()
153
+ if not text:
154
+ return 0.0
155
+ try:
156
+ return float(text)
157
+ except Exception:
158
+ pass
159
+ for fmt in (None, "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
160
+ try:
161
+ if fmt is None:
162
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
163
+ return datetime.strptime(text, fmt).timestamp()
164
+ except Exception:
165
+ continue
166
+ return 0.0
167
+
168
+
169
+ def _recency_score(row: dict) -> float:
170
+ reference = _parse_timestamp(row.get("last_guard_hit_at")) or _parse_timestamp(row.get("updated_at")) or _parse_timestamp(row.get("created_at"))
171
+ if not reference:
172
+ return 0.35
173
+ age_days = max(0.0, (now_epoch() - reference) / 86400.0)
174
+ if age_days <= 7:
175
+ return 1.0
176
+ if age_days <= 30:
177
+ return 0.8
178
+ if age_days <= 90:
179
+ return 0.6
180
+ if age_days <= 180:
181
+ return 0.4
182
+ return 0.25
183
+
184
+
185
+ def _usefulness_score(row: dict) -> float:
186
+ hits = int(row.get("guard_hits") or 0)
187
+ if hits >= 5:
188
+ return 1.0
189
+ if hits >= 3:
190
+ return 0.85
191
+ if hits >= 1:
192
+ return 0.65
193
+ return 0.35 if str(row.get("status") or "active") == "active" else 0.15
194
+
195
+
196
+ def _source_richness_score(row: dict) -> float:
197
+ parts = 0.0
198
+ if str(row.get("reasoning") or "").strip():
199
+ parts += 0.25
200
+ if str(row.get("prevention") or "").strip():
201
+ parts += 0.25
202
+ if str(row.get("applies_to") or "").strip():
203
+ parts += 0.2
204
+ if row.get("review_due_at"):
205
+ parts += 0.15
206
+ if len(str(row.get("content") or "").strip()) >= 80:
207
+ parts += 0.15
208
+ return min(1.0, parts)
209
+
210
+
211
+ def _contradiction_pressure_score(conn, row: dict) -> float:
212
+ if str(row.get("status") or "active") != "active":
213
+ return 0.7
214
+ conflicting = _find_conflicting_active_learning(
215
+ conn,
216
+ category=str(row.get("category") or ""),
217
+ title=str(row.get("title") or ""),
218
+ content=str(row.get("content") or ""),
219
+ applies_to=str(row.get("applies_to") or ""),
220
+ exclude_id=int(row.get("id") or 0),
221
+ )
222
+ return 0.0 if conflicting else 1.0
223
+
224
+
225
+ def score_learning_quality(row: dict, conn=None) -> dict:
226
+ """Compute a 0-100 quality score for a learning using usefulness and conflict pressure."""
227
+ conn = conn or get_db()
228
+ confidence = min(1.0, (_priority_score(row.get("priority")) * 0.45) + (float(row.get("weight") or 0.5) * 0.55))
229
+ usefulness = _usefulness_score(row)
230
+ recency = _recency_score(row)
231
+ contradiction = _contradiction_pressure_score(conn, row)
232
+ source_richness = _source_richness_score(row)
233
+ status = str(row.get("status") or "active")
234
+ status_multiplier = 1.0 if status == "active" else 0.65 if status in {"pending_review", "review"} else 0.45
235
+ overall = (
236
+ confidence * 0.28
237
+ + usefulness * 0.24
238
+ + recency * 0.18
239
+ + contradiction * 0.18
240
+ + source_richness * 0.12
241
+ ) * status_multiplier
242
+ score = max(0, min(100, round(overall * 100)))
243
+ if score >= 80:
244
+ label = "strong"
245
+ elif score >= 60:
246
+ label = "usable"
247
+ elif score >= 40:
248
+ label = "weak"
249
+ else:
250
+ label = "fragile"
251
+ return {
252
+ "score": score,
253
+ "label": label,
254
+ "confidence": round(confidence * 100),
255
+ "usefulness": round(usefulness * 100),
256
+ "recency_relevance": round(recency * 100),
257
+ "contradiction_pressure": round((1.0 - contradiction) * 100),
258
+ "source_richness": round(source_richness * 100),
259
+ }
260
+
261
+
262
+ def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
263
+ prevention: str = '', applies_to: str = '', review_days: int = 30,
264
+ priority: str = 'medium', supersedes_id: int = 0) -> str:
265
+ """Add a new learning entry to the specified category.
266
+
267
+ Args:
268
+ category: Free-form category name (e.g., 'backend', 'frontend', 'devops', 'infrastructure', 'security', 'nexo-ops'). Use consistent names — learnings are grouped and searched by category.
269
+ title: Short title for the learning
270
+ content: Full description of what was learned
271
+ reasoning: WHY this matters — what led to discovering this, what was the context
272
+ prevention: Concrete rule/check that prevents repeating this mistake
273
+ applies_to: Files, systems, or areas this learning applies to
274
+ review_days: Days until this learning should be reviewed again
275
+ priority: critical, high, medium, low (default: medium)
276
+ """
277
+ if priority not in ('critical', 'high', 'medium', 'low'):
278
+ priority = 'medium'
279
+ category = category.lower().strip()
280
+ if not category:
281
+ return "ERROR: Category cannot be empty."
282
+ # Dedup guard: block exact title duplicates in same category
283
+ conn = get_db()
284
+ existing = conn.execute(
285
+ "SELECT id, title FROM learnings WHERE LOWER(title) = LOWER(?) AND category = ? AND status = 'active'",
286
+ (title.strip(), category)
287
+ ).fetchone()
288
+ if existing:
289
+ return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
290
+ conflicting = _find_conflicting_active_learning(
291
+ conn,
292
+ category=category,
293
+ title=title,
294
+ content=content,
295
+ applies_to=applies_to,
296
+ )
297
+ if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
298
+ return (
299
+ f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
300
+ f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
301
+ f"Supersede or update the existing canonical rule instead of creating two active file rules."
302
+ )
303
+ result = create_learning(
304
+ category, title, content, reasoning=reasoning, supersedes_id=(int(supersedes_id) if supersedes_id else None)
305
+ )
306
+ if "error" in result:
307
+ return f"ERROR: {result['error']}"
308
+ if prevention or applies_to or review_days > 0 or priority != 'medium':
309
+ initial_weight = {'critical': 0.9, 'high': 0.7, 'medium': 0.5, 'low': 0.3}[priority]
310
+ updated_at = now_epoch()
311
+ review_due_at = now_epoch() + (max(1, int(review_days)) * 86400)
312
+ conn = get_db()
313
+ conn.execute(
314
+ "UPDATE learnings SET prevention = ?, applies_to = ?, status = COALESCE(status, 'active'), "
315
+ "review_due_at = ?, updated_at = ?, priority = ?, weight = ? WHERE id = ?",
316
+ (prevention, applies_to, review_due_at, updated_at,
317
+ priority, initial_weight, result["id"])
318
+ )
319
+ conn.commit()
320
+ result = dict(result)
321
+ result.update({
322
+ "prevention": prevention,
323
+ "applies_to": applies_to,
324
+ "status": result.get("status") or "active",
325
+ "review_due_at": review_due_at,
326
+ "updated_at": updated_at,
327
+ "priority": priority,
328
+ "weight": initial_weight,
329
+ })
330
+
331
+ # Cognitive ingest — embed learning for semantic search
332
+ new_id = result["id"]
333
+ try:
334
+ import cognitive
335
+ cognitive.ingest(f"{title}: {content}", "learning", f"L{new_id}", title, category)
336
+ except Exception:
337
+ pass
338
+
339
+ # Similarity check — detect repeated errors
340
+ matches = find_similar_learnings(new_id, title, content, category)
341
+ repetition_msg = ""
342
+ if matches:
343
+ conn = get_db()
344
+ for original_id, similarity in matches:
345
+ conn.execute(
346
+ "INSERT INTO error_repetitions (new_learning_id, original_learning_id, similarity, area) VALUES (?,?,?,?)",
347
+ (new_id, original_id, similarity, category)
348
+ )
349
+ conn.commit()
350
+ repetition_msg = f"\n⚠️ REPETITION WARNING: Similar to {len(matches)} existing learning(s): " + \
351
+ ", ".join(f"#{m[0]} ({m[1]:.0%})" for m in matches[:3])
352
+
353
+ # Somatic event logging (append-only in nexo.db, projected to cognitive.db nightly)
354
+ try:
355
+ if applies_to:
356
+ for file_path in [f.strip() for f in applies_to.split(",") if f.strip()]:
357
+ get_db().execute(
358
+ "INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
359
+ (file_path, "file", "learning_add", 0.15, f"learning:{new_id}")
360
+ )
361
+ # Area + extra file pain ONLY for repeated errors
362
+ if matches:
363
+ get_db().execute(
364
+ "INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
365
+ (category, "area", "error_repetition", 0.15, f"learning:{new_id}")
366
+ )
367
+ if applies_to:
368
+ for file_path in [f.strip() for f in applies_to.split(",") if f.strip()]:
369
+ get_db().execute(
370
+ "INSERT INTO somatic_events (target, target_type, event_type, delta, source) VALUES (?, ?, ?, ?, ?)",
371
+ (file_path, "file", "error_repetition", 0.25, f"learning:{new_id}")
372
+ )
373
+ get_db().commit()
374
+ except Exception:
375
+ pass # Somatic event logging is best-effort
376
+
377
+ # Knowledge graph incremental population
378
+ try:
379
+ from kg_populate import on_learning_add
380
+ on_learning_add(new_id, category, title, applies_to)
381
+ except Exception:
382
+ pass
383
+
384
+ if supersedes_id:
385
+ superseded = supersede_learning(int(supersedes_id), new_id, f"Superseded by learning #{new_id}.")
386
+ if "error" in superseded:
387
+ return f"ERROR: Learning #{new_id} created but supersede failed: {superseded['error']}"
388
+
389
+ # Post-insert verification: confirm the learning actually persisted
390
+ verify_conn = get_db()
391
+ verified = verify_conn.execute(
392
+ "SELECT id, title, category FROM learnings WHERE id = ? AND status = 'active'",
393
+ (result["id"],)
394
+ ).fetchone()
395
+ if not verified:
396
+ return (
397
+ f"⚠ PERSISTENCE FAILURE: Learning #{result['id']} was inserted but NOT found on verification read. "
398
+ f"Retry nexo_learning_add or investigate DB integrity."
399
+ )
400
+
401
+ # Fase 2 item 3: when a learning lands with an enforceable prevention
402
+ # rule, scan recent decisions for ones that would have been decided
403
+ # differently and surface them as deterministic followups. Best-effort:
404
+ # a failure here must NEVER block the learning insert that the user
405
+ # already verified above.
406
+ retro_meta_msg = ""
407
+ if prevention:
408
+ try:
409
+ from retroactive_learnings import apply_learning_retroactively
410
+ retro_result = apply_learning_retroactively(
411
+ int(result["id"]),
412
+ lookback_days=14,
413
+ max_matches=5,
414
+ min_score=0.4,
415
+ )
416
+ if retro_result.get("followups_created"):
417
+ retro_meta_msg = (
418
+ f"\n📜 Retroactive scan: {retro_result['followups_created']} "
419
+ f"past decision(s) flagged for review (scanned "
420
+ f"{retro_result.get('scanned', 0)})"
421
+ )
422
+ except Exception:
423
+ pass # Best-effort surfacing only
424
+
425
+ meta = []
426
+ if prevention:
427
+ meta.append("with prevention")
428
+ if applies_to:
429
+ meta.append(f"applies_to={applies_to}")
430
+ if supersedes_id:
431
+ meta.append(f"supersedes={int(supersedes_id)}")
432
+ meta_str = f" ({', '.join(meta)})" if meta else ""
433
+ return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}"
434
+
435
+
436
+ def handle_learning_search(query: str, category: str = '') -> str:
437
+ """Search learnings by query string, optionally filtered by category."""
438
+ results = search_learnings(query, category if category else None)
439
+ if not results:
440
+ return f"No results for '{query}'."
441
+ lines = [f"RESULTS ({len(results)}):"]
442
+ conn = get_db()
443
+ for r in results:
444
+ snippet = r["content"][:100] + "..." if len(r["content"]) > 100 else r["content"]
445
+ status = r.get("status", "active")
446
+ review_due = r.get("review_due_at")
447
+ review_note = f" | review_due={review_due:.0f}" if isinstance(review_due, (int, float)) and review_due else ""
448
+ pri = r.get("priority", "medium") or "medium"
449
+ w = r.get("weight", 0.5) or 0.5
450
+ quality = score_learning_quality(r, conn)
451
+ pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
452
+ lines.append(f" #{r['id']} [{r['category']}] [{status}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}{review_note}")
453
+ lines.append(f" {snippet}")
454
+ if r.get("prevention"):
455
+ lines.append(f" Prevention: {r['prevention'][:100]}")
456
+
457
+ # v1.2: Passive rehearsal — strengthen matching cognitive memories
458
+ try:
459
+ import cognitive
460
+ for r in results[:5]:
461
+ cognitive.rehearse_by_content(f"{r.get('title', '')} {r.get('content', '')[:200]}")
462
+ except Exception:
463
+ pass
464
+
465
+ return "\n".join(lines)
466
+
467
+
468
+ def handle_learning_update(id: int, title: str = '', content: str = '', category: str = '',
469
+ reasoning: str = '', prevention: str = '', applies_to: str = '',
470
+ status: str = '', review_days: int = 0, priority: str = '',
471
+ supersedes_id: int = 0) -> str:
472
+ """Update an existing learning, including review metadata and priority."""
473
+ conn = get_db()
474
+ current = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
475
+ if not current:
476
+ return f"ERROR: Learning #{id} not found."
477
+ kwargs = {}
478
+ if title:
479
+ kwargs["title"] = title
480
+ if content:
481
+ kwargs["content"] = content
482
+ if category:
483
+ kwargs["category"] = category.lower().strip()
484
+ if reasoning:
485
+ kwargs["reasoning"] = reasoning
486
+ if prevention:
487
+ kwargs["prevention"] = prevention
488
+ if applies_to:
489
+ kwargs["applies_to"] = applies_to
490
+ if status:
491
+ kwargs["status"] = status
492
+ if review_days > 0:
493
+ kwargs["review_days"] = review_days
494
+ if not kwargs:
495
+ return "ERROR: Nothing to update. Provide new fields."
496
+ effective_category = kwargs.get("category", current["category"])
497
+ effective_title = kwargs.get("title", current["title"])
498
+ effective_content = kwargs.get("content", current["content"])
499
+ effective_applies_to = kwargs.get("applies_to", current["applies_to"])
500
+ effective_status = kwargs.get("status", current["status"])
501
+ if effective_status != "superseded":
502
+ conflicting = _find_conflicting_active_learning(
503
+ conn,
504
+ category=effective_category,
505
+ title=effective_title,
506
+ content=effective_content,
507
+ applies_to=effective_applies_to,
508
+ exclude_id=id,
509
+ )
510
+ if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
511
+ return (
512
+ f"ERROR: Update would conflict with active learning #{conflicting['id']} "
513
+ f"for applies_to={conflicting.get('applies_to', '')}. "
514
+ f"Supersede the old rule or merge into one canonical learning."
515
+ )
516
+ basic_kwargs = {k: v for k, v in kwargs.items() if k in {"title", "content", "category", "reasoning"}}
517
+ result = update_learning(id, **basic_kwargs)
518
+ if "error" in result:
519
+ return f"ERROR: {result['error']}"
520
+ extra_updates = {}
521
+ if prevention:
522
+ extra_updates["prevention"] = prevention
523
+ if applies_to:
524
+ extra_updates["applies_to"] = applies_to
525
+ if status:
526
+ extra_updates["status"] = status
527
+ if priority and priority in ('critical', 'high', 'medium', 'low'):
528
+ extra_updates["priority"] = priority
529
+ extra_updates["weight"] = {'critical': 0.9, 'high': 0.7, 'medium': 0.5, 'low': 0.3}[priority]
530
+ if review_days > 0:
531
+ extra_updates["review_due_at"] = now_epoch() + (max(1, int(review_days)) * 86400)
532
+ if extra_updates:
533
+ extra_updates["updated_at"] = now_epoch()
534
+ set_clause = ", ".join(f"{k} = ?" for k in extra_updates)
535
+ values = list(extra_updates.values()) + [id]
536
+ conn = get_db()
537
+ conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
538
+ conn.commit()
539
+ if supersedes_id:
540
+ superseded = supersede_learning(int(supersedes_id), id, f"Superseded by learning #{id}.")
541
+ if "error" in superseded:
542
+ return f"ERROR: Learning #{id} updated but supersede failed: {superseded['error']}"
543
+ return f"Learning #{id} updated."
544
+
545
+
546
+ def handle_learning_delete(id: int) -> str:
547
+ """Delete a learning entry by ID."""
548
+ deleted = delete_learning(id)
549
+ if not deleted:
550
+ return f"ERROR: Learning #{id} not found."
551
+ return f"Learning #{id} deleted."
552
+
553
+
554
+ def handle_learning_list(category: str = '') -> str:
555
+ """List all learnings, grouped by category if no filter given."""
556
+ results = list_learnings(category if category else None)
557
+ if not results:
558
+ label = category if category else "ALL"
559
+ return f"LEARNINGS {label} (0): No entries."
560
+
561
+ conn = get_db()
562
+ if category:
563
+ label = category.upper()
564
+ lines = [f"LEARNINGS {label} ({len(results)}):"]
565
+ for r in results:
566
+ pri = r.get("priority", "medium") or "medium"
567
+ w = r.get("weight", 0.5) or 0.5
568
+ quality = score_learning_quality(r, conn)
569
+ pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
570
+ lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
571
+ else:
572
+ lines = [f"LEARNINGS ALL ({len(results)}):"]
573
+ current_cat = None
574
+ for r in results:
575
+ if r["category"] != current_cat:
576
+ current_cat = r["category"]
577
+ lines.append(f"\n [{current_cat.upper()}]")
578
+ pri = r.get("priority", "medium") or "medium"
579
+ w = r.get("weight", 0.5) or 0.5
580
+ quality = score_learning_quality(r, conn)
581
+ pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
582
+ lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
583
+
584
+ return "\n".join(lines)
585
+
586
+
587
+ def handle_learning_quality(id: int = 0, category: str = "", status: str = "active", limit: int = 20) -> str:
588
+ """Inspect memory quality so fragile learnings can be tightened before they mislead guard/retrieval."""
589
+ results = list_learnings(category if category else None)
590
+ if id:
591
+ results = [row for row in results if int(row.get("id") or 0) == int(id)]
592
+ if status:
593
+ results = [row for row in results if str(row.get("status") or "").lower() == str(status).lower()]
594
+ results = results[: max(1, int(limit or 20))]
595
+ if not results:
596
+ return "LEARNING QUALITY (0): No matching learnings."
597
+
598
+ conn = get_db()
599
+ scored = []
600
+ for row in results:
601
+ quality = score_learning_quality(row, conn)
602
+ scored.append((row, quality))
603
+ avg_score = round(sum(item[1]["score"] for item in scored) / len(scored))
604
+ weak = [item for item in scored if item[1]["score"] < 60]
605
+ lines = [f"LEARNING QUALITY ({len(scored)}) avg={avg_score} weak={len(weak)}:"]
606
+ for row, quality in scored:
607
+ lines.append(
608
+ f" #{row['id']} q={quality['score']} [{quality['label']}] {row['title']} "
609
+ f"(conf={quality['confidence']} useful={quality['usefulness']} recency={quality['recency_relevance']} "
610
+ f"pressure={quality['contradiction_pressure']} richness={quality['source_richness']})"
611
+ )
612
+ return "\n".join(lines)