nexo-brain 2.7.0 → 3.0.1

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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -12
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +295 -7
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +99 -1
  8. package/src/client_sync.py +207 -3
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +141 -1
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/protocol.html +199 -0
  14. package/src/db/__init__.py +23 -1
  15. package/src/db/_learnings.py +31 -4
  16. package/src/db/_personal_scripts.py +12 -0
  17. package/src/db/_protocol.py +303 -0
  18. package/src/db/_schema.py +248 -0
  19. package/src/db/_watchers.py +173 -0
  20. package/src/db/_workflow.py +952 -0
  21. package/src/doctor/providers/boot.py +45 -19
  22. package/src/doctor/providers/runtime.py +923 -8
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/requirements.txt +1 -0
  38. package/src/script_registry.py +142 -0
  39. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  40. package/src/scripts/deep-sleep/collect.py +49 -4
  41. package/src/scripts/nexo-agent-run.py +2 -0
  42. package/src/scripts/nexo-daily-self-audit.py +843 -5
  43. package/src/scripts/nexo-evolution-run.py +343 -1
  44. package/src/server.py +92 -6
  45. package/src/skills_runtime.py +151 -0
  46. package/src/state_watchers_runtime.py +334 -0
  47. package/src/tools_learnings.py +345 -7
  48. package/src/tools_sessions.py +183 -0
  49. package/templates/CLAUDE.md.template +9 -1
  50. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -5,10 +5,69 @@ and provides stats on error prevention effectiveness.
5
5
  """
6
6
  import json
7
7
  import os
8
+ import re
8
9
  from datetime import datetime, timedelta
10
+ from pathlib import Path
9
11
  from db import get_db, find_similar_learnings, extract_keywords, search_learnings, search_changes
10
12
 
11
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
+
12
71
 
13
72
  def _load_schema_cache() -> dict:
14
73
  """Load cached DB schemas from schema_cache.json."""
@@ -69,17 +128,49 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
69
128
  result = {
70
129
  "learnings": [],
71
130
  "universal_rules": [],
131
+ "conditioned_learnings": [],
72
132
  "schemas": {},
73
133
  "area_repetition_rate": 0.0,
74
134
  "blocking_rules": [],
75
135
  }
76
136
 
77
137
  seen_ids = set()
138
+ conditioned_blocking_seen = set()
139
+ conditioned_by_file = _load_conditioned_learnings(conn, file_list) if file_list else {}
78
140
 
79
- # 1. By file path learnings mentioning the file name or parent directory
141
+ # 1. File-conditioned learningsexplicit applies_to guardrails for target files
80
142
  hit_ids = []
81
143
  for filepath in file_list:
82
- from pathlib import Path
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:
83
174
  p = Path(filepath)
84
175
  filename = p.name
85
176
  parent_dir = p.parent.name
@@ -96,7 +187,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
96
187
  w = r["weight"] or 0.5
97
188
  result["learnings"].append({"id": r["id"], "category": r["category"], "rule": r["title"], "priority": pri, "weight": w})
98
189
 
99
- # 2. By area/category
190
+ # 3. By area/category
100
191
  if area:
101
192
  rows = conn.execute(
102
193
  "SELECT id, category, title, content, priority, weight FROM learnings WHERE category = ?",
@@ -110,14 +201,14 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
110
201
  w = r["weight"] or 0.5
111
202
  result["learnings"].append({"id": r["id"], "category": r["category"], "rule": r["title"], "priority": pri, "weight": w})
112
203
 
113
- # 3. Universal rules — only from matching area or nexo-ops (not ALL learnings)
204
+ # 4. Universal rules — only from matching area or nexo-ops (not ALL learnings)
114
205
  universal_categories = {"nexo-ops"}
115
206
  if area:
116
207
  universal_categories.add(area)
117
208
  placeholders = ",".join("?" for _ in universal_categories)
118
209
  rows = conn.execute(
119
210
  f"SELECT id, category, title, content, priority FROM learnings WHERE "
120
- f"category IN ({placeholders}) AND ("
211
+ f"category IN ({placeholders}) AND COALESCE(applies_to, '') = '' AND ("
121
212
  f"content LIKE '%SIEMPRE%' OR content LIKE '%NUNCA%' OR content LIKE '%ANTES%' "
122
213
  f"OR content LIKE '%always%' OR content LIKE '%never%')",
123
214
  tuple(universal_categories)
@@ -127,7 +218,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
127
218
  seen_ids.add(r["id"])
128
219
  result["universal_rules"].append({"id": r["id"], "rule": r["title"], "category": r["category"], "priority": r["priority"] or "medium"})
129
220
 
130
- # 4. DB schemas if files contain SQL keywords
221
+ # 5. DB schemas if files contain SQL keywords
131
222
  if include_schemas_bool and file_list:
132
223
  all_tables = set()
133
224
  for filepath in file_list:
@@ -149,7 +240,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
149
240
  elif "cloud_sql" in cache and table in cache["cloud_sql"]:
150
241
  result["schemas"][table] = cache["cloud_sql"][table]
151
242
 
152
- # 5. Check for blocking rules — two paths:
243
+ # 6. Check for blocking rules — two paths:
153
244
  # (a) 5+ repetitions (existing behavior)
154
245
  # (b) Learning contains NUNCA/NEVER/PROHIBIDO and matches semantically (aggressive mode)
155
246
  import re
@@ -160,7 +251,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
160
251
  # Check both learnings and universal_rules for blocking
161
252
  all_candidates = [(l, "learning") for l in result["learnings"]] + \
162
253
  [(u, "universal") for u in result["universal_rules"]]
163
- blocking_seen = set()
254
+ blocking_seen = set(conditioned_blocking_seen)
164
255
  for learning, source in all_candidates:
165
256
  lid = learning["id"]
166
257
  if lid in blocking_seen:
@@ -188,7 +279,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
188
279
  "reason": "prohibition_keyword"
189
280
  })
190
281
 
191
- # 5b. Behavioral rules — when called without files (session-level check)
282
+ # 6b. Behavioral rules — when called without files (session-level check)
192
283
  if not file_list:
193
284
  behavioral = conn.execute(
194
285
  """SELECT l.id, l.title, l.category, COUNT(e.id) as violations
@@ -205,7 +296,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
205
296
  for r in behavioral
206
297
  ]
207
298
 
208
- # 6. Area repetition rate
299
+ # 7. Area repetition rate
209
300
  if area:
210
301
  total_area = conn.execute(
211
302
  "SELECT COUNT(*) as cnt FROM learnings WHERE category = ?", (area,)
@@ -216,7 +307,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
216
307
  if total_area > 0:
217
308
  result["area_repetition_rate"] = round(reps_area / total_area, 2)
218
309
 
219
- # 7. Cognitive metacognition — semantic search for related warnings
310
+ # 8. Cognitive metacognition — semantic search for related warnings
220
311
  # Trust score modulates rigor: <40 = paranoid mode (more results, lower threshold)
221
312
  cognitive_warnings = []
222
313
  trust_note = ""
@@ -255,7 +346,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
255
346
  except Exception:
256
347
  pass # Cognitive is optional
257
348
 
258
- # 8. Somatic markers — risk score per file/area
349
+ # 9. Somatic markers — risk score per file/area
259
350
  somatic_risk = 0.0
260
351
  somatic_details = {}
261
352
  try:
@@ -297,12 +388,20 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
297
388
  lines.append("BLOCKING RULES (resolve BEFORE writing):")
298
389
  for r in result["blocking_rules"]:
299
390
  reason = r.get("reason", "repeated_error")
300
- if reason == "prohibition_keyword":
391
+ if reason == "file_conditioned":
392
+ lines.append(f" #{r['id']} [FILE RULE:{r.get('file', '')}]: {r['rule']}")
393
+ elif reason == "prohibition_keyword":
301
394
  lines.append(f" #{r['id']} [PROHIBIT]: {r['rule']}")
302
395
  else:
303
396
  lines.append(f" #{r['id']} ({r['repetitions']}x repeated): {r['rule']}")
304
397
  lines.append("")
305
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
+
306
405
  if result["learnings"]:
307
406
  shown = result["learnings"][:10] # Cap at 10, not 15
308
407
  lines.append(f"RELEVANT LEARNINGS ({len(result['learnings'])}, showing {len(shown)}):")
@@ -609,9 +708,6 @@ def handle_guard_file_check(files: list) -> str:
609
708
  Args:
610
709
  files: List of file paths about to be edited
611
710
  """
612
- from pathlib import Path
613
- import re
614
-
615
711
  BLOCKING_KEYWORDS = re.compile(
616
712
  r'\bNUNCA\b|\bNEVER\b|\bPROHIBIDO\b|\bFORBIDDEN\b|\bBLOCKING\b',
617
713
  re.IGNORECASE
@@ -624,6 +720,8 @@ def handle_guard_file_check(files: list) -> str:
624
720
  recent_changes: dict = {}
625
721
  warnings: list = []
626
722
  seen_learning_ids: set = set()
723
+ conn = get_db()
724
+ conditioned_by_file = _load_conditioned_learnings(conn, files)
627
725
 
628
726
  for filepath in files:
629
727
  p = Path(filepath)
@@ -643,10 +741,28 @@ def handle_guard_file_check(files: list) -> str:
643
741
  file_results = []
644
742
  file_seen_ids: set = set()
645
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
+
646
759
  for keyword in unique_keywords:
647
760
  try:
648
761
  rows = search_learnings(keyword)
649
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
650
766
  lid = r.get("id")
651
767
  if lid and lid not in seen_learning_ids and lid not in file_seen_ids:
652
768
  file_seen_ids.add(lid)