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,862 @@
1
+ """Guard plugin — Error prevention closed-loop system.
2
+
3
+ Surfaces relevant learnings at the moment of action, tracks repetitions,
4
+ and provides stats on error prevention effectiveness.
5
+ """
6
+ import json
7
+ import os
8
+ import re
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from db import get_db, find_similar_learnings, extract_keywords, search_learnings, search_changes
12
+
13
+
14
+ def _split_applies_to(applies_to: str) -> list[str]:
15
+ return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
16
+
17
+
18
+ def _normalize_path_token(value: str) -> str:
19
+ return str(value or "").replace("\\", "/").rstrip("/").lower()
20
+
21
+
22
+ def _applies_to_matches_file(applies_to: str, filepath: str) -> bool:
23
+ file_path = Path(filepath)
24
+ file_norm = _normalize_path_token(str(file_path))
25
+ parent_norm = _normalize_path_token(str(file_path.parent))
26
+ filename = file_path.name.lower()
27
+ stem = file_path.stem.lower()
28
+ parent_name = file_path.parent.name.lower()
29
+
30
+ for raw in _split_applies_to(applies_to):
31
+ token_norm = _normalize_path_token(raw)
32
+ if not token_norm:
33
+ continue
34
+ if "/" in token_norm:
35
+ if (
36
+ file_norm == token_norm
37
+ or file_norm.endswith(f"/{token_norm}")
38
+ or file_norm.startswith(f"{token_norm}/")
39
+ or parent_norm == token_norm
40
+ or parent_norm.endswith(f"/{token_norm}")
41
+ ):
42
+ return True
43
+ continue
44
+ if token_norm in {filename, stem, parent_name}:
45
+ return True
46
+ return False
47
+
48
+
49
+ def _load_conditioned_learnings(conn, file_list: list[str]) -> dict[str, list[dict]]:
50
+ conditioned = {filepath: [] for filepath in file_list}
51
+ if not file_list:
52
+ return conditioned
53
+ rows = conn.execute(
54
+ """
55
+ SELECT id, category, title, content, prevention, applies_to,
56
+ COALESCE(priority, 'medium') as priority,
57
+ COALESCE(weight, 0.5) as weight
58
+ FROM learnings
59
+ WHERE status = 'active' AND COALESCE(applies_to, '') != ''
60
+ ORDER BY COALESCE(weight, 0.5) DESC, updated_at DESC
61
+ """
62
+ ).fetchall()
63
+ for row in rows:
64
+ entry = dict(row)
65
+ for filepath in file_list:
66
+ if _applies_to_matches_file(entry.get("applies_to", ""), filepath):
67
+ conditioned[filepath].append(entry)
68
+ return conditioned
69
+
70
+
71
+
72
+ def _load_schema_cache() -> dict:
73
+ """Load cached DB schemas from schema_cache.json."""
74
+ try:
75
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "schema_cache.json")
76
+ if os.path.exists(path):
77
+ with open(path) as f:
78
+ return json.load(f)
79
+ except Exception:
80
+ pass
81
+ return {}
82
+
83
+
84
+ def _get_nexo_table_schema(table_name: str) -> str:
85
+ """Get schema for a nexo.db table via PRAGMA."""
86
+ conn = get_db()
87
+ try:
88
+ rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
89
+ if rows:
90
+ cols = [f"{r['name']}({r['type']})" for r in rows]
91
+ return ", ".join(cols)
92
+ except Exception:
93
+ pass
94
+ return ""
95
+
96
+
97
+ def _extract_table_names(content: str) -> set:
98
+ """Extract SQL table names from source code."""
99
+ import re
100
+ tables = set()
101
+ # Match FROM/JOIN/INTO/UPDATE/TABLE patterns
102
+ patterns = [
103
+ r'(?:FROM|JOIN|INTO|UPDATE)\s+`?(\w+)`?',
104
+ r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?',
105
+ r'DESCRIBE\s+`?(\w+)`?',
106
+ r'table_info\([\'\"]?(\w+)[\'\"]?\)',
107
+ ]
108
+ for pat in patterns:
109
+ for m in re.finditer(pat, content, re.IGNORECASE):
110
+ tables.add(m.group(1))
111
+ # Filter out SQL keywords that might match
112
+ sql_keywords = {'SELECT', 'WHERE', 'AND', 'OR', 'NOT', 'NULL', 'SET', 'VALUES', 'INTO', 'AS'}
113
+ return {t for t in tables if t.upper() not in sql_keywords}
114
+
115
+
116
+ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "true") -> str:
117
+ """Check learnings relevant to files/area before editing. Call BEFORE any code change.
118
+
119
+ Args:
120
+ files: Comma-separated file paths about to be edited
121
+ area: System area (webapp, shopify, infrastructure, nexo-ops, etc.)
122
+ include_schemas: Include DB table schemas if files touch database code (true/false)
123
+ """
124
+ conn = get_db()
125
+ include_schemas_bool = include_schemas.lower() in ("true", "1", "yes")
126
+ file_list = [f.strip() for f in files.split(",") if f.strip()] if files else []
127
+
128
+ result = {
129
+ "learnings": [],
130
+ "universal_rules": [],
131
+ "conditioned_learnings": [],
132
+ "schemas": {},
133
+ "area_repetition_rate": 0.0,
134
+ "blocking_rules": [],
135
+ }
136
+
137
+ seen_ids = set()
138
+ conditioned_blocking_seen = set()
139
+ conditioned_by_file = _load_conditioned_learnings(conn, file_list) if file_list else {}
140
+
141
+ # 1. File-conditioned learnings — explicit applies_to guardrails for target files
142
+ hit_ids = []
143
+ for filepath in file_list:
144
+ for row in conditioned_by_file.get(filepath, []):
145
+ if row["id"] not in seen_ids:
146
+ seen_ids.add(row["id"])
147
+ hit_ids.append(row["id"])
148
+ result["learnings"].append({
149
+ "id": row["id"],
150
+ "category": row["category"],
151
+ "rule": row["title"],
152
+ "priority": row.get("priority", "medium") or "medium",
153
+ "weight": row.get("weight", 0.5) or 0.5,
154
+ })
155
+ result["conditioned_learnings"].append({
156
+ "id": row["id"],
157
+ "file": filepath,
158
+ "category": row["category"],
159
+ "rule": row["title"],
160
+ "applies_to": row.get("applies_to", ""),
161
+ })
162
+ if row["id"] not in conditioned_blocking_seen:
163
+ conditioned_blocking_seen.add(row["id"])
164
+ result["blocking_rules"].append({
165
+ "id": row["id"],
166
+ "rule": row["title"],
167
+ "repetitions": 0,
168
+ "reason": "file_conditioned",
169
+ "file": filepath,
170
+ })
171
+
172
+ # 2. By file path — learnings mentioning the file name or parent directory
173
+ for filepath in file_list:
174
+ p = Path(filepath)
175
+ filename = p.name
176
+ parent_dir = p.parent.name
177
+
178
+ rows = conn.execute(
179
+ "SELECT id, category, title, content, priority, weight FROM learnings WHERE INSTR(content, ?) > 0 OR INSTR(content, ?) > 0",
180
+ (filename, parent_dir)
181
+ ).fetchall()
182
+ for r in rows:
183
+ if r["id"] not in seen_ids:
184
+ seen_ids.add(r["id"])
185
+ hit_ids.append(r["id"])
186
+ pri = r["priority"] or "medium"
187
+ w = r["weight"] or 0.5
188
+ result["learnings"].append({"id": r["id"], "category": r["category"], "rule": r["title"], "priority": pri, "weight": w})
189
+
190
+ # 3. By area/category
191
+ if area:
192
+ rows = conn.execute(
193
+ "SELECT id, category, title, content, priority, weight FROM learnings WHERE category = ?",
194
+ (area,)
195
+ ).fetchall()
196
+ for r in rows:
197
+ if r["id"] not in seen_ids:
198
+ seen_ids.add(r["id"])
199
+ hit_ids.append(r["id"])
200
+ pri = r["priority"] or "medium"
201
+ w = r["weight"] or 0.5
202
+ result["learnings"].append({"id": r["id"], "category": r["category"], "rule": r["title"], "priority": pri, "weight": w})
203
+
204
+ # 4. Universal rules — only from matching area or nexo-ops (not ALL learnings)
205
+ universal_categories = {"nexo-ops"}
206
+ if area:
207
+ universal_categories.add(area)
208
+ placeholders = ",".join("?" for _ in universal_categories)
209
+ rows = conn.execute(
210
+ f"SELECT id, category, title, content, priority FROM learnings WHERE "
211
+ f"category IN ({placeholders}) AND COALESCE(applies_to, '') = '' AND ("
212
+ f"content LIKE '%SIEMPRE%' OR content LIKE '%NUNCA%' OR content LIKE '%ANTES%' "
213
+ f"OR content LIKE '%always%' OR content LIKE '%never%')",
214
+ tuple(universal_categories)
215
+ ).fetchall()
216
+ for r in rows:
217
+ if r["id"] not in seen_ids:
218
+ seen_ids.add(r["id"])
219
+ result["universal_rules"].append({"id": r["id"], "rule": r["title"], "category": r["category"], "priority": r["priority"] or "medium"})
220
+
221
+ # 5. DB schemas if files contain SQL keywords
222
+ if include_schemas_bool and file_list:
223
+ all_tables = set()
224
+ for filepath in file_list:
225
+ try:
226
+ with open(filepath, 'r', errors='ignore') as f:
227
+ content = f.read()
228
+ sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE TABLE']
229
+ if any(kw in content.upper() for kw in sql_keywords):
230
+ all_tables.update(_extract_table_names(content))
231
+ except (FileNotFoundError, PermissionError):
232
+ continue
233
+
234
+ cache = _load_schema_cache()
235
+ for table in all_tables:
236
+ # Try nexo.db first
237
+ schema = _get_nexo_table_schema(table)
238
+ if schema:
239
+ result["schemas"][table] = schema
240
+ elif "cloud_sql" in cache and table in cache["cloud_sql"]:
241
+ result["schemas"][table] = cache["cloud_sql"][table]
242
+
243
+ # 6. Check for blocking rules — two paths:
244
+ # (a) 5+ repetitions (existing behavior)
245
+ # (b) Learning contains NUNCA/NEVER/PROHIBIDO and matches semantically (aggressive mode)
246
+ import re
247
+ BLOCKING_KEYWORDS = re.compile(
248
+ r'\bNUNCA\b|\bNEVER\b|\bPROHIBIDO\b|\bNO\s+\w+\b|\bFORBIDDEN\b|\bBLOCKING\b|\bSIEMPRE\b|\bALWAYS\b',
249
+ re.IGNORECASE
250
+ )
251
+ # Check both learnings and universal_rules for blocking
252
+ all_candidates = [(l, "learning") for l in result["learnings"]] + \
253
+ [(u, "universal") for u in result["universal_rules"]]
254
+ blocking_seen = set(conditioned_blocking_seen)
255
+ for learning, source in all_candidates:
256
+ lid = learning["id"]
257
+ if lid in blocking_seen:
258
+ continue
259
+ rep_count = conn.execute(
260
+ "SELECT COUNT(*) as cnt FROM error_repetitions WHERE original_learning_id = ?",
261
+ (lid,)
262
+ ).fetchone()["cnt"]
263
+
264
+ # Path (a): 5+ repetitions
265
+ if rep_count >= 5:
266
+ blocking_seen.add(lid)
267
+ result["blocking_rules"].append({
268
+ "id": lid, "rule": learning["rule"], "repetitions": rep_count,
269
+ "reason": "repeated_error"
270
+ })
271
+ continue
272
+
273
+ # Path (b): Only promote to blocking if high/critical priority AND title has prohibition keyword
274
+ pri = learning.get("priority", "medium")
275
+ if pri in ("critical", "high") and BLOCKING_KEYWORDS.search(learning["rule"]):
276
+ blocking_seen.add(lid)
277
+ result["blocking_rules"].append({
278
+ "id": lid, "rule": learning["rule"], "repetitions": rep_count,
279
+ "reason": "prohibition_keyword"
280
+ })
281
+
282
+ # 6b. Behavioral rules — when called without files (session-level check)
283
+ if not file_list:
284
+ behavioral = conn.execute(
285
+ """SELECT l.id, l.title, l.category, COUNT(e.id) as violations
286
+ FROM learnings l
287
+ LEFT JOIN error_repetitions e ON e.original_learning_id = l.id
288
+ WHERE l.category = 'nexo-ops' AND l.status = 'active'
289
+ GROUP BY l.id
290
+ ORDER BY violations DESC, l.created_at DESC
291
+ LIMIT 5"""
292
+ ).fetchall()
293
+ if behavioral:
294
+ result["behavioral_rules"] = [
295
+ {"id": r["id"], "rule": r["title"], "violations": r["violations"]}
296
+ for r in behavioral
297
+ ]
298
+
299
+ # 7. Area repetition rate
300
+ if area:
301
+ total_area = conn.execute(
302
+ "SELECT COUNT(*) as cnt FROM learnings WHERE category = ?", (area,)
303
+ ).fetchone()["cnt"]
304
+ reps_area = conn.execute(
305
+ "SELECT COUNT(*) as cnt FROM error_repetitions WHERE area = ?", (area,)
306
+ ).fetchone()["cnt"]
307
+ if total_area > 0:
308
+ result["area_repetition_rate"] = round(reps_area / total_area, 2)
309
+
310
+ # 8. Cognitive metacognition — semantic search for related warnings
311
+ # Trust score modulates rigor: <40 = paranoid mode (more results, lower threshold)
312
+ cognitive_warnings = []
313
+ trust_note = ""
314
+ try:
315
+ import cognitive
316
+ trust = cognitive.get_trust_score()
317
+
318
+ # Rigor modulation based on trust
319
+ if trust < 40:
320
+ cog_top_k = 6 # More results
321
+ cog_min_score = 0.55 # Lower threshold = catch more
322
+ trust_note = f" [RIGOR: PARANOID — trust={trust:.0f}]"
323
+ elif trust > 80:
324
+ cog_top_k = 2 # Fewer results
325
+ cog_min_score = 0.75 # Higher threshold = only strong matches
326
+ trust_note = f" [RIGOR: FLUENT — trust={trust:.0f}]"
327
+ else:
328
+ cog_top_k = 3
329
+ cog_min_score = 0.65
330
+
331
+ query_parts = []
332
+ if file_list:
333
+ query_parts.append(f"editing files: {', '.join(file_list[:5])}")
334
+ if area:
335
+ query_parts.append(f"area: {area}")
336
+ if query_parts:
337
+ query_text = ". ".join(query_parts)
338
+ cog_results = cognitive.search(
339
+ query_text, top_k=cog_top_k, min_score=cog_min_score,
340
+ stores="ltm", source_type_filter="learning", rehearse=False
341
+ )
342
+ for r in cog_results:
343
+ cognitive_warnings.append(
344
+ f"[{r['score']:.2f}]: {r['source_title']} — {r['content'][:200]}"
345
+ )
346
+ except Exception:
347
+ pass # Cognitive is optional
348
+
349
+ # 9. Somatic markers — risk score per file/area
350
+ somatic_risk = 0.0
351
+ somatic_details = {}
352
+ try:
353
+ import cognitive
354
+ risk_result = cognitive.somatic_get_risk(file_list, area)
355
+ somatic_risk = risk_result["max_risk"]
356
+ somatic_details = risk_result["scores"]
357
+ # Validated recovery: if no learnings found, guard check is "clean"
358
+ if not result["learnings"]:
359
+ for fp in file_list:
360
+ cognitive.somatic_guard_decay(fp, "file")
361
+ except Exception:
362
+ pass
363
+
364
+ # Record guard hits on learnings (for weight auto-adjustment)
365
+ import time
366
+ if hit_ids:
367
+ for lid in hit_ids:
368
+ conn.execute(
369
+ "UPDATE learnings SET guard_hits = COALESCE(guard_hits, 0) + 1, last_guard_hit_at = ? WHERE id = ?",
370
+ (time.time(), lid)
371
+ )
372
+
373
+ # Log the guard check
374
+ conn.execute(
375
+ "INSERT INTO guard_checks (session_id, files, area, learnings_returned, blocking_rules_returned) "
376
+ "VALUES (?, ?, ?, ?, ?)",
377
+ ("", files, area, len(result["learnings"]) + len(result["universal_rules"]),
378
+ len(result["blocking_rules"]))
379
+ )
380
+ conn.commit()
381
+
382
+ # Sort learnings by weight (highest first)
383
+ result["learnings"].sort(key=lambda x: x.get("weight", 0.5), reverse=True)
384
+
385
+ # Format output
386
+ lines = []
387
+ if result["blocking_rules"]:
388
+ lines.append("BLOCKING RULES (resolve BEFORE writing):")
389
+ for r in result["blocking_rules"]:
390
+ reason = r.get("reason", "repeated_error")
391
+ if reason == "file_conditioned":
392
+ lines.append(f" #{r['id']} [FILE RULE:{r.get('file', '')}]: {r['rule']}")
393
+ elif reason == "prohibition_keyword":
394
+ lines.append(f" #{r['id']} [PROHIBIT]: {r['rule']}")
395
+ else:
396
+ lines.append(f" #{r['id']} ({r['repetitions']}x repeated): {r['rule']}")
397
+ lines.append("")
398
+
399
+ if result["conditioned_learnings"]:
400
+ lines.append(f"FILE-CONDITIONED LEARNINGS ({len(result['conditioned_learnings'])}):")
401
+ for item in result["conditioned_learnings"][:10]:
402
+ lines.append(f" #{item['id']} [{item['file']}] {item['rule']}")
403
+ lines.append("")
404
+
405
+ if result["learnings"]:
406
+ shown = result["learnings"][:10] # Cap at 10, not 15
407
+ lines.append(f"RELEVANT LEARNINGS ({len(result['learnings'])}, showing {len(shown)}):")
408
+ for l in shown:
409
+ lines.append(f" #{l['id']} [{l['category']}] {l['rule']}")
410
+ lines.append("")
411
+
412
+ if result.get("behavioral_rules"):
413
+ lines.append("SESSION BEHAVIORAL RULES (top 5 most-violated):")
414
+ for r in result["behavioral_rules"]:
415
+ v = f" ({r['violations']}x violated)" if r["violations"] > 0 else ""
416
+ lines.append(f" #{r['id']} {r['rule']}{v}")
417
+ lines.append("")
418
+
419
+ if result["universal_rules"]:
420
+ shown_u = result["universal_rules"][:5] # Cap at 5
421
+ lines.append(f"UNIVERSAL RULES ({len(result['universal_rules'])}, showing {len(shown_u)}):")
422
+ for r in shown_u:
423
+ lines.append(f" #{r['id']} {r['rule']}")
424
+ lines.append("")
425
+
426
+ if result["schemas"]:
427
+ lines.append("DB SCHEMAS:")
428
+ for table, schema in result["schemas"].items():
429
+ lines.append(f" {table}: {schema}")
430
+ lines.append("")
431
+
432
+ if result["area_repetition_rate"] > 0:
433
+ lines.append(f"Area repetition rate: {result['area_repetition_rate']:.0%}")
434
+
435
+ if cognitive_warnings:
436
+ lines.append(f"\nCOGNITIVE SEMANTIC MATCHES{trust_note}:")
437
+ for w in cognitive_warnings:
438
+ lines.append(f" COGNITIVE MATCH {w}")
439
+
440
+ if somatic_risk > 0:
441
+ if somatic_risk > 0.8:
442
+ lines.insert(0, "CRITICAL RISK (score {:.2f}) — suggest code review before editing".format(somatic_risk))
443
+ elif somatic_risk > 0.5:
444
+ lines.insert(0, "HIGH RISK (score {:.2f}) — extra caution recommended".format(somatic_risk))
445
+ else:
446
+ lines.append("\nSomatic risk: {:.2f} (low)".format(somatic_risk))
447
+ if somatic_details:
448
+ lines.append("Risk scores:")
449
+ for target, data in somatic_details.items():
450
+ lines.append(" {}: {:.2f} ({} incidents, last: {})".format(
451
+ target, data["risk"], data["incidents"], data["last"][:10] if data["last"] else "unknown"))
452
+
453
+ if not lines:
454
+ return "No relevant learnings found for these files/area."
455
+
456
+ return "\n".join(lines)
457
+
458
+
459
+ def handle_guard_stats(period_days: int = 7) -> str:
460
+ """Get guard system statistics for the specified period.
461
+
462
+ Args:
463
+ period_days: Number of days to look back (default 7)
464
+ """
465
+ conn = get_db()
466
+ cutoff = (datetime.now() - timedelta(days=period_days)).strftime("%Y-%m-%d %H:%M:%S")
467
+
468
+ total_learnings = conn.execute("SELECT COUNT(*) as cnt FROM learnings").fetchone()["cnt"]
469
+
470
+ total_reps = conn.execute(
471
+ "SELECT COUNT(*) as cnt FROM error_repetitions WHERE created_at > ?", (cutoff,)
472
+ ).fetchone()["cnt"]
473
+
474
+ # Repetition rate
475
+ new_learnings_period = conn.execute(
476
+ "SELECT COUNT(*) as cnt FROM learnings WHERE created_at > ?",
477
+ ((datetime.now() - timedelta(days=period_days)).timestamp(),)
478
+ ).fetchone()["cnt"]
479
+ rep_rate = round(total_reps / new_learnings_period, 2) if new_learnings_period > 0 else 0.0
480
+
481
+ # Previous period for trend
482
+ prev_cutoff = (datetime.now() - timedelta(days=period_days * 2)).strftime("%Y-%m-%d %H:%M:%S")
483
+ prev_reps = conn.execute(
484
+ "SELECT COUNT(*) as cnt FROM error_repetitions WHERE created_at > ? AND created_at <= ?",
485
+ (prev_cutoff, cutoff)
486
+ ).fetchone()["cnt"]
487
+ trend = "stable"
488
+ if total_reps < prev_reps:
489
+ trend = "improving"
490
+ elif total_reps > prev_reps:
491
+ trend = "worsening"
492
+
493
+ # Top areas
494
+ area_rows = conn.execute(
495
+ "SELECT area, COUNT(*) as cnt FROM error_repetitions WHERE created_at > ? GROUP BY area ORDER BY cnt DESC LIMIT 5",
496
+ (cutoff,)
497
+ ).fetchall()
498
+
499
+ # Most ignored learnings (most repetitions)
500
+ ignored_rows = conn.execute(
501
+ "SELECT original_learning_id, COUNT(*) as cnt FROM error_repetitions "
502
+ "GROUP BY original_learning_id ORDER BY cnt DESC LIMIT 5"
503
+ ).fetchall()
504
+ most_ignored = []
505
+ for r in ignored_rows:
506
+ lr = conn.execute("SELECT title FROM learnings WHERE id = ?", (r["original_learning_id"],)).fetchone()
507
+ if lr:
508
+ most_ignored.append({"id": r["original_learning_id"], "title": lr["title"], "times_repeated": r["cnt"]})
509
+
510
+ # Guard checks performed
511
+ checks_count = conn.execute(
512
+ "SELECT COUNT(*) as cnt FROM guard_checks WHERE created_at > ?", (cutoff,)
513
+ ).fetchone()["cnt"]
514
+
515
+ lines = [
516
+ f"GUARD STATS (last {period_days} days):",
517
+ f" Repetition rate: {rep_rate:.0%} ({trend})",
518
+ f" Total learnings: {total_learnings}",
519
+ f" Repetitions in period: {total_reps}",
520
+ f" Guard checks performed: {checks_count}",
521
+ ]
522
+
523
+ if area_rows:
524
+ lines.append(" Top areas:")
525
+ for r in area_rows:
526
+ lines.append(f" {r['area']}: {r['cnt']} repetitions")
527
+
528
+ if most_ignored:
529
+ lines.append(" Most repeated learnings:")
530
+ for m in most_ignored:
531
+ lines.append(f" #{m['id']} ({m['times_repeated']}x): {m['title'][:60]}")
532
+
533
+ return "\n".join(lines)
534
+
535
+
536
+ def handle_guard_log_repetition(new_learning_id: int, original_learning_id: int, similarity: float = 0.75) -> str:
537
+ """Log that a new learning is similar to an existing one (repetition detected).
538
+
539
+ Args:
540
+ new_learning_id: ID of the new learning
541
+ original_learning_id: ID of the original learning it matches
542
+ similarity: Similarity score (0-1)
543
+ """
544
+ conn = get_db()
545
+
546
+ # Get the area from the new learning
547
+ row = conn.execute("SELECT category FROM learnings WHERE id = ?", (new_learning_id,)).fetchone()
548
+ if not row:
549
+ return f"ERROR: Learning #{new_learning_id} not found."
550
+ area = row["category"]
551
+
552
+ conn.execute(
553
+ "INSERT INTO error_repetitions (new_learning_id, original_learning_id, similarity, area) VALUES (?,?,?,?)",
554
+ (new_learning_id, original_learning_id, similarity, area)
555
+ )
556
+ conn.commit()
557
+
558
+ return f"Repetition logged: #{new_learning_id} similar to #{original_learning_id} ({similarity:.0%})"
559
+
560
+
561
+ def handle_somatic_check(files: str = "", area: str = "") -> str:
562
+ """View somatic risk scores for specific files and/or area.
563
+ Args:
564
+ files: Comma-separated file paths to check
565
+ area: System area to check
566
+ """
567
+ try:
568
+ import cognitive
569
+ file_list = [f.strip() for f in files.split(",") if f.strip()] if files else []
570
+ result = cognitive.somatic_get_risk(file_list, area)
571
+ if not result["scores"]:
572
+ return "No somatic markers found for these targets."
573
+ lines = ["Max risk: {:.2f}".format(result["max_risk"]), ""]
574
+ for target, data in result["scores"].items():
575
+ level = "CRITICAL" if data["risk"] > 0.8 else "HIGH" if data["risk"] > 0.5 else "Low"
576
+ lines.append(" {} {}: {:.2f} ({} incidents, last: {})".format(
577
+ level, target, data["risk"], data["incidents"], data["last"][:10] if data["last"] else "unknown"))
578
+ return "\n".join(lines)
579
+ except Exception as e:
580
+ return "Error: {}".format(e)
581
+
582
+
583
+ def handle_somatic_stats() -> str:
584
+ """View top 10 riskiest files/areas and system-wide risk distribution."""
585
+ try:
586
+ import cognitive
587
+ top = cognitive.somatic_top_risks(limit=10)
588
+ if not top:
589
+ return "No somatic markers recorded yet."
590
+ lines = ["TOP RISK TARGETS:", ""]
591
+ for r in top:
592
+ level = "CRIT" if r["risk_score"] > 0.8 else "HIGH" if r["risk_score"] > 0.5 else "low"
593
+ lines.append(" [{}] [{}] {}: {:.2f} ({} incidents)".format(
594
+ level, r["target_type"], r["target"], r["risk_score"], r["incident_count"]))
595
+ db = cognitive._get_db()
596
+ total = db.execute("SELECT COUNT(*) FROM somatic_markers WHERE risk_score > 0").fetchone()[0]
597
+ high = db.execute("SELECT COUNT(*) FROM somatic_markers WHERE risk_score > 0.5").fetchone()[0]
598
+ critical = db.execute("SELECT COUNT(*) FROM somatic_markers WHERE risk_score > 0.8").fetchone()[0]
599
+ lines.extend(["", "Distribution: {} tracked | {} high risk | {} critical".format(total, high, critical)])
600
+ return "\n".join(lines)
601
+ except Exception as e:
602
+ return "Error: {}".format(e)
603
+
604
+
605
+ def handle_guard_cross_check(findings: list, area: str = "") -> str:
606
+ """Cross-check audit findings against known learnings to filter false positives.
607
+
608
+ Args:
609
+ findings: List of audit finding strings to cross-check
610
+ area: System area to narrow the learning search (webapp, shopify, etc.)
611
+ """
612
+ # Common English/Spanish stopwords to skip during keyword extraction
613
+ STOPWORDS = {
614
+ "the", "a", "an", "is", "in", "on", "at", "to", "of", "and", "or", "but",
615
+ "for", "with", "that", "this", "it", "as", "are", "was", "be", "by", "not",
616
+ "has", "have", "from", "which", "when", "if", "then", "do", "does", "can",
617
+ "el", "la", "los", "las", "un", "una", "en", "de", "del", "al", "y", "o",
618
+ "que", "se", "no", "es", "por", "con", "su", "pero", "como", "para",
619
+ "este", "esta", "esto", "son", "hay", "más", "ya",
620
+ }
621
+
622
+ new_issues = []
623
+ known_issues = []
624
+
625
+ for finding in findings:
626
+ if not finding or not finding.strip():
627
+ continue
628
+
629
+ # Extract significant keywords from the finding text
630
+ words = finding.lower().split()
631
+ keywords = [
632
+ w.strip(".,;:!?\"'()[]{}") for w in words
633
+ if len(w) >= 4 and w.lower() not in STOPWORDS
634
+ ]
635
+ # Use up to 5 most distinctive keywords to build the search query
636
+ query_keywords = keywords[:5]
637
+
638
+ matched_learnings = []
639
+ if query_keywords:
640
+ query = " ".join(query_keywords)
641
+ try:
642
+ results = search_learnings(query, category=area if area else None)
643
+ if not results and area:
644
+ # Retry without category filter if area-filtered search returns nothing
645
+ results = search_learnings(query)
646
+ matched_learnings = results[:3] # Top 3 matches per finding
647
+ except Exception:
648
+ pass
649
+
650
+ if matched_learnings:
651
+ refs = [
652
+ {"id": r["id"], "title": r["title"], "category": r.get("category", "")}
653
+ for r in matched_learnings
654
+ ]
655
+ known_issues.append({
656
+ "finding": finding,
657
+ "status": "known",
658
+ "learning_refs": refs,
659
+ })
660
+ else:
661
+ new_issues.append({
662
+ "finding": finding,
663
+ "status": "new",
664
+ })
665
+
666
+ # Build output
667
+ lines = [
668
+ f"CROSS-CHECK RESULTS: {len(findings)} findings — "
669
+ f"{len(new_issues)} new, {len(known_issues)} already documented",
670
+ "",
671
+ ]
672
+
673
+ if new_issues:
674
+ lines.append(f"NEW ISSUES ({len(new_issues)}) — not in learnings, investigate:")
675
+ for i, item in enumerate(new_issues, 1):
676
+ lines.append(f" {i}. {item['finding']}")
677
+ lines.append("")
678
+
679
+ if known_issues:
680
+ lines.append(f"KNOWN ISSUES ({len(known_issues)}) — covered by existing learnings:")
681
+ for i, item in enumerate(known_issues, 1):
682
+ refs_str = ", ".join(
683
+ f"#{r['id']} [{r['category']}] {r['title'][:60]}"
684
+ for r in item["learning_refs"]
685
+ )
686
+ lines.append(f" {i}. {item['finding']}")
687
+ lines.append(f" -> {refs_str}")
688
+ lines.append("")
689
+
690
+ summary = {
691
+ "total": len(findings),
692
+ "new_count": len(new_issues),
693
+ "known_count": len(known_issues),
694
+ "new_issues": [i["finding"] for i in new_issues],
695
+ "known_issues": [
696
+ {"finding": i["finding"], "refs": i["learning_refs"]}
697
+ for i in known_issues
698
+ ],
699
+ }
700
+ lines.append(f"SUMMARY JSON: {json.dumps(summary)}")
701
+
702
+ return "\n".join(lines)
703
+
704
+
705
+ def handle_guard_file_check(files: list) -> str:
706
+ """Pre-edit check: surfaces learnings and recent changes for files about to be modified.
707
+
708
+ Args:
709
+ files: List of file paths about to be edited
710
+ """
711
+ BLOCKING_KEYWORDS = re.compile(
712
+ r'\bNUNCA\b|\bNEVER\b|\bPROHIBIDO\b|\bFORBIDDEN\b|\bBLOCKING\b',
713
+ re.IGNORECASE
714
+ )
715
+
716
+ if not files:
717
+ return "ERROR: No files provided."
718
+
719
+ file_learnings: dict = {}
720
+ recent_changes: dict = {}
721
+ warnings: list = []
722
+ seen_learning_ids: set = set()
723
+ conn = get_db()
724
+ conditioned_by_file = _load_conditioned_learnings(conn, files)
725
+
726
+ for filepath in files:
727
+ p = Path(filepath)
728
+ filename = p.name
729
+ parent_dir = p.parent.name
730
+ stem = p.stem # filename without extension
731
+
732
+ # Build search keywords: filename, stem, parent directory (deduplicated)
733
+ keywords = [kw for kw in [filename, stem, parent_dir] if kw and kw not in (".", "")]
734
+ seen_kw: set = set()
735
+ unique_keywords = []
736
+ for kw in keywords:
737
+ if kw not in seen_kw:
738
+ seen_kw.add(kw)
739
+ unique_keywords.append(kw)
740
+
741
+ file_results = []
742
+ file_seen_ids: set = set()
743
+
744
+ for row in conditioned_by_file.get(filepath, []):
745
+ lid = row.get("id")
746
+ if lid and lid not in seen_learning_ids and lid not in file_seen_ids:
747
+ file_seen_ids.add(lid)
748
+ seen_learning_ids.add(lid)
749
+ file_results.append({
750
+ "id": lid,
751
+ "category": row.get("category", ""),
752
+ "title": row.get("title", ""),
753
+ "content": (row.get("content") or row.get("prevention") or "")[:300],
754
+ })
755
+ warnings.append(
756
+ f"[BLOCKING] #{row.get('id')} ({filepath}): conditioned learning — {row.get('title', '')}"
757
+ )
758
+
759
+ for keyword in unique_keywords:
760
+ try:
761
+ rows = search_learnings(keyword)
762
+ for r in rows:
763
+ applies_to = str(r.get("applies_to") or "").strip()
764
+ if applies_to and not _applies_to_matches_file(applies_to, filepath):
765
+ continue
766
+ lid = r.get("id")
767
+ if lid and lid not in seen_learning_ids and lid not in file_seen_ids:
768
+ file_seen_ids.add(lid)
769
+ seen_learning_ids.add(lid)
770
+ entry = {
771
+ "id": lid,
772
+ "category": r.get("category", ""),
773
+ "title": r.get("title", ""),
774
+ "content": (r.get("content") or "")[:300],
775
+ }
776
+ file_results.append(entry)
777
+ # Flag blocking learnings
778
+ if BLOCKING_KEYWORDS.search(r.get("title", "")) or \
779
+ BLOCKING_KEYWORDS.search(r.get("content") or ""):
780
+ warnings.append(
781
+ f"[BLOCKING] #{lid} ({filepath}): {r.get('title', '')}"
782
+ )
783
+ except Exception:
784
+ pass
785
+
786
+ file_learnings[filepath] = file_results
787
+
788
+ # Search recent changes (last 7 days) for this file by filename/stem
789
+ file_changes = []
790
+ for keyword in unique_keywords[:2]: # filename + stem are most specific
791
+ try:
792
+ changes = search_changes(files=keyword, days=7)
793
+ for c in changes:
794
+ cid = c.get("id")
795
+ if cid and not any(fc.get("id") == cid for fc in file_changes):
796
+ file_changes.append({
797
+ "id": cid,
798
+ "files": c.get("files", ""),
799
+ "what_changed": (c.get("what_changed") or "")[:200],
800
+ "why": (c.get("why") or "")[:150],
801
+ "created_at": (c.get("created_at") or "")[:16],
802
+ })
803
+ except Exception:
804
+ pass
805
+
806
+ recent_changes[filepath] = file_changes
807
+
808
+ # Build summary line
809
+ total_learnings = sum(len(v) for v in file_learnings.values())
810
+ total_changes = sum(len(v) for v in recent_changes.values())
811
+ summary_parts = []
812
+ if total_learnings:
813
+ summary_parts.append(f"{total_learnings} learning(s) found")
814
+ if total_changes:
815
+ summary_parts.append(f"{total_changes} recent change(s) in last 7 days")
816
+ if warnings:
817
+ summary_parts.append(f"{len(warnings)} BLOCKING warning(s)")
818
+ summary = ", ".join(summary_parts) if summary_parts else "No relevant learnings or recent changes found."
819
+
820
+ # Format output
821
+ lines = []
822
+
823
+ if warnings:
824
+ lines.append("WARNINGS — resolve before editing:")
825
+ for w in warnings:
826
+ lines.append(f" {w}")
827
+ lines.append("")
828
+
829
+ for filepath in files:
830
+ learnings = file_learnings.get(filepath, [])
831
+ changes = recent_changes.get(filepath, [])
832
+ if not learnings and not changes:
833
+ continue
834
+ lines.append(f"FILE: {filepath}")
835
+ if learnings:
836
+ lines.append(f" Learnings ({len(learnings)}):")
837
+ for entry in learnings[:10]:
838
+ lines.append(f" #{entry['id']} [{entry['category']}] {entry['title']}")
839
+ if entry["content"]:
840
+ lines.append(f" {entry['content'][:120]}")
841
+ if changes:
842
+ lines.append(f" Recent changes ({len(changes)}, last 7d):")
843
+ for c in changes[:5]:
844
+ lines.append(f" [{c['created_at']}] {c['what_changed'][:100]}")
845
+ if c["why"]:
846
+ lines.append(f" Why: {c['why'][:80]}")
847
+ lines.append("")
848
+
849
+ lines.append(f"SUMMARY: {summary}")
850
+
851
+ return "\n".join(lines) if lines else summary
852
+
853
+
854
+ TOOLS = [
855
+ (handle_guard_check, "nexo_guard_check", "Check learnings relevant to files/area BEFORE editing code. Call this before any code change."),
856
+ (handle_guard_stats, "nexo_guard_stats", "Get guard system statistics: repetition rate, trends, top problem areas"),
857
+ (handle_guard_log_repetition, "nexo_guard_log_repetition", "Log a learning repetition (new learning matches existing one)"),
858
+ (handle_somatic_check, "nexo_somatic_check", "View somatic risk scores for files/areas — pain memory"),
859
+ (handle_somatic_stats, "nexo_somatic_stats", "Top 10 riskiest targets + risk distribution"),
860
+ (handle_guard_cross_check, "nexo_guard_cross_check", "Cross-check audit findings against known learnings to filter false positives"),
861
+ (handle_guard_file_check, "nexo_guard_file_check", "Pre-edit check: surfaces learnings and recent changes for files about to be modified"),
862
+ ]