nexo-brain 1.4.0 → 1.4.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 (90) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  4. package/src/__pycache__/cognitive.cpython-314.pyc +0 -0
  5. package/src/__pycache__/db.cpython-314.pyc +0 -0
  6. package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
  7. package/src/__pycache__/kg_populate.cpython-314.pyc +0 -0
  8. package/src/__pycache__/knowledge_graph.cpython-314.pyc +0 -0
  9. package/src/__pycache__/maintenance.cpython-314.pyc +0 -0
  10. package/src/__pycache__/migrate_embeddings.cpython-314.pyc +0 -0
  11. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  12. package/src/__pycache__/server.cpython-314.pyc +0 -0
  13. package/src/__pycache__/storage_router.cpython-314.pyc +0 -0
  14. package/src/__pycache__/tools_coordination.cpython-314.pyc +0 -0
  15. package/src/__pycache__/tools_credentials.cpython-314.pyc +0 -0
  16. package/src/__pycache__/tools_learnings.cpython-314.pyc +0 -0
  17. package/src/__pycache__/tools_menu.cpython-314.pyc +0 -0
  18. package/src/__pycache__/tools_reminders.cpython-314.pyc +0 -0
  19. package/src/__pycache__/tools_reminders_crud.cpython-314.pyc +0 -0
  20. package/src/__pycache__/tools_sessions.cpython-314.pyc +0 -0
  21. package/src/__pycache__/tools_task_history.cpython-314.pyc +0 -0
  22. package/src/cognitive.py +63 -14
  23. package/src/dashboard/__pycache__/__init__.cpython-314.pyc +0 -0
  24. package/src/dashboard/__pycache__/app.cpython-314.pyc +0 -0
  25. package/src/dashboard/app.py +1 -1
  26. package/src/db.py +29 -13
  27. package/src/evolution_cycle.py +72 -94
  28. package/src/hooks/__pycache__/auto_capture.cpython-314.pyc +0 -0
  29. package/src/hooks/session-start.sh +5 -2
  30. package/src/hooks/session-stop.sh +79 -133
  31. package/src/knowledge_graph.py +3 -3
  32. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  34. package/src/plugins/__pycache__/agents.cpython-314.pyc +0 -0
  35. package/src/plugins/__pycache__/artifact_registry.cpython-314.pyc +0 -0
  36. package/src/plugins/__pycache__/backup.cpython-314.pyc +0 -0
  37. package/src/plugins/__pycache__/cognitive_memory.cpython-314.pyc +0 -0
  38. package/src/plugins/__pycache__/core_rules.cpython-314.pyc +0 -0
  39. package/src/plugins/__pycache__/cortex.cpython-314.pyc +0 -0
  40. package/src/plugins/__pycache__/entities.cpython-314.pyc +0 -0
  41. package/src/plugins/__pycache__/episodic_memory.cpython-314.pyc +0 -0
  42. package/src/plugins/__pycache__/evolution.cpython-314.pyc +0 -0
  43. package/src/plugins/__pycache__/guard.cpython-314.pyc +0 -0
  44. package/src/plugins/__pycache__/knowledge_graph_tools.cpython-314.pyc +0 -0
  45. package/src/plugins/__pycache__/preferences.cpython-314.pyc +0 -0
  46. package/src/plugins/artifact_registry.py +450 -0
  47. package/src/plugins/cognitive_memory.py +9 -9
  48. package/src/plugins/episodic_memory.py +8 -8
  49. package/src/plugins/evolution.py +4 -4
  50. package/src/plugins/guard.py +26 -2
  51. package/src/rules/__pycache__/__init__.cpython-314.pyc +0 -0
  52. package/src/rules/__pycache__/migrate.cpython-314.pyc +0 -0
  53. package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
  54. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  55. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  56. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  57. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  58. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  59. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  60. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  61. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  62. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  63. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  64. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  65. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  66. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  67. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  68. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  69. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  70. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  71. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  72. package/src/scripts/nexo-brain-activation.sh +140 -0
  73. package/src/scripts/nexo-evolution-run.py +1 -1
  74. package/src/scripts/nexo-followup-hygiene.py +107 -0
  75. package/src/scripts/nexo-postmortem-consolidator.py +25 -25
  76. package/src/scripts/nexo-pre-commit.py +118 -0
  77. package/src/scripts/nexo-proactive-dashboard.py +342 -0
  78. package/src/scripts/nexo-runtime-preflight.py +270 -0
  79. package/src/scripts/nexo-send-email.py +25 -0
  80. package/src/scripts/nexo-send-reply.py +176 -0
  81. package/src/scripts/nexo-snapshot-restore.sh +34 -0
  82. package/src/scripts/nexo-watchdog-smoke.py +114 -0
  83. package/src/server.py +5 -5
  84. package/src/tools_coordination.py +3 -3
  85. package/src/tools_learnings.py +1 -1
  86. package/src/tools_menu.py +31 -49
  87. package/src/tools_reminders_crud.py +1 -1
  88. package/src/tools_sessions.py +12 -12
  89. package/src/rules/__init__ 2.py +0 -0
  90. package/src/rules/migrate 2.py +0 -207
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # nexo-brain-activation.sh — NF24: Activación Espontánea
3
+ # Lee user_model.json, detecta shifts significativos y genera insights
4
+ # para el arranque de NEXO. Si no hay nada relevante: exit 0 sin output.
5
+
6
+ set -euo pipefail
7
+
8
+ BRAIN_DIR="~/.nexo/brain"
9
+ MODEL_FILE="$BRAIN_DIR/user_model.json"
10
+ SUMMARIES_DIR="$BRAIN_DIR/daily_summaries"
11
+
12
+ # --- Guardia: archivo imprescindible ---
13
+ if [[ ! -f "$MODEL_FILE" ]]; then
14
+ exit 0
15
+ fi
16
+
17
+ # --- Python inline para parsear JSON y analizar ---
18
+ python3 - <<'PYEOF'
19
+ import json
20
+ import sys
21
+ import os
22
+ import glob
23
+ from datetime import datetime, timedelta
24
+
25
+ BRAIN_DIR = os.path.expanduser("~/.nexo/brain")
26
+ MODEL_FILE = os.path.join(BRAIN_DIR, "user_model.json")
27
+ SUMMARIES_DIR = os.path.join(BRAIN_DIR, "daily_summaries")
28
+
29
+ # ── Cargar modelo ──────────────────────────────────────────────────────────
30
+ try:
31
+ with open(MODEL_FILE) as f:
32
+ model = json.load(f)
33
+ except Exception:
34
+ sys.exit(0)
35
+
36
+ insights = []
37
+
38
+ # ── 1. Analizar evolution_log — últimas 3-5 entradas ─────────────────────
39
+ evolution = model.get("evolution_log", [])
40
+ recent = evolution[-5:] if len(evolution) >= 5 else evolution
41
+
42
+ # Detectar entradas de los últimos 3 días
43
+ today = datetime.now().date()
44
+ cutoff = today - timedelta(days=3)
45
+
46
+ recent_obs = []
47
+ for entry in recent:
48
+ try:
49
+ entry_date = datetime.strptime(entry["date"], "%Y-%m-%d").date()
50
+ if entry_date >= cutoff:
51
+ recent_obs.append(entry)
52
+ except Exception:
53
+ pass
54
+
55
+ if recent_obs:
56
+ for obs in recent_obs[-2:]: # max 2 most recent
57
+ insights.append(obs["observation"].strip())
58
+
59
+ # ── 2. Detectar cambios de traits >0.1 entre entradas (si hay histórico) ──
60
+ # El modelo actual solo tiene snapshot actual; si en el futuro hay histórico
61
+ # en evolution_log con trait deltas, se puede ampliar aquí.
62
+ # Detect the most extreme trait as the dominant identity signal.
63
+ traits = model.get("traits", {})
64
+ if traits:
65
+ dominant = max(traits, key=lambda k: traits[k])
66
+ dominant_val = traits[dominant]
67
+ weakest = min(traits, key=lambda k: traits[k])
68
+ weakest_val = traits[weakest]
69
+ # Solo reportar si es muy extremo (>0.9 o <0.2) para no saturar
70
+ if dominant_val >= 0.9:
71
+ insights.append(f"Trait dominante: {dominant}={dominant_val} (máximo registrado)")
72
+ if weakest_val <= 0.2:
73
+ insights.append(f"Trait en mínimo: {weakest}={weakest_val} (baja tolerancia activa)")
74
+
75
+ # ── 3. Contradicciones recientes (últimos 3 días) ─────────────────────────
76
+ contradictions = model.get("contradictions", [])
77
+ recent_contradictions = []
78
+ for c in contradictions:
79
+ try:
80
+ c_date = datetime.strptime(c["date"], "%Y-%m-%d").date()
81
+ if c_date >= cutoff:
82
+ recent_contradictions.append(c)
83
+ except Exception:
84
+ pass
85
+
86
+ for c in recent_contradictions:
87
+ insights.append(f"Contradicción detectada: {c['description'].strip()}")
88
+
89
+ # ── 4. Foco activo actual ─────────────────────────────────────────────────
90
+ current_focus = model.get("current_focus", [])
91
+ # Solo incluir si hay focos (y no es el arranque inicial)
92
+ if len(current_focus) > 0 and len(insights) == 0:
93
+ # Si no hay otros insights, reportar foco como contexto mínimo
94
+ focus_str = ", ".join(current_focus)
95
+ insights.append(f"Foco actual: {focus_str}")
96
+
97
+ # ── 5. Goals: cambios active/dormant ─────────────────────────────────────
98
+ goals_active = model.get("goals_active", [])
99
+ goals_dormant = model.get("goals_dormant", [])
100
+ # Si hay goals dormant, mencionarlos (pueden necesitar reactivación)
101
+ if goals_dormant:
102
+ insights.append(f"Goal dormant: {goals_dormant[0]}")
103
+
104
+ # ── Leer último daily summary ─────────────────────────────────────────────
105
+ summary_text = ""
106
+ try:
107
+ files = sorted(glob.glob(os.path.join(SUMMARIES_DIR, "*.md")))
108
+ if files:
109
+ with open(files[-1]) as f:
110
+ lines = [l.rstrip() for l in f.readlines() if l.strip()]
111
+ # Take up to 3 lines of content (exclude header)
112
+ content_lines = [l for l in lines if not l.startswith("# Resumen")]
113
+ summary_text = " | ".join(content_lines[:3])
114
+ except Exception:
115
+ pass
116
+
117
+ # ── Output ────────────────────────────────────────────────────────────────
118
+ # Si no hay insights reales, salir sin output
119
+ if not insights and not summary_text:
120
+ sys.exit(0)
121
+
122
+ # Deduplicar y limitar
123
+ seen = set()
124
+ unique_insights = []
125
+ for ins in insights:
126
+ key = ins[:60]
127
+ if key not in seen:
128
+ seen.add(key)
129
+ unique_insights.append(ins)
130
+
131
+ if unique_insights:
132
+ print("BRAIN_INSIGHTS:")
133
+ for ins in unique_insights[:5]:
134
+ print(f"- {ins}")
135
+
136
+ if summary_text:
137
+ print("RECENT_SUMMARY:")
138
+ print(summary_text[:400])
139
+
140
+ PYEOF
@@ -319,7 +319,7 @@ def _create_review_followup(conn: sqlite3.Connection, cycle_num: int,
319
319
  public_items = [i for i in items if i.get("scope") == "public"]
320
320
  local_items = [i for i in items if i.get("scope") != "public"]
321
321
 
322
- lines = [f"Evolution Cycle #{cycle_num} — {len(items)} propuestas para revisar."]
322
+ lines = [f"Evolution Cycle #{cycle_num} — {len(items)} proposals to review."]
323
323
  lines.append(f"Análisis: {analysis[:200]}")
324
324
  lines.append("")
325
325
 
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NEXO Followup Hygiene — Weekly cleanup of followup/reminder statuses.
4
+
5
+ Runs Sundays via LaunchAgent (or manually). Tasks:
6
+ 1. Normalize dirty statuses (COMPLETED YYYY-MM-DD → COMPLETED)
7
+ 2. Flag PENDING followups >14 days without updates as STALE
8
+ 3. Generate summary of orphaned/forgotten followups for synthesis
9
+
10
+ No CLI needed — this is pure mechanical cleanup.
11
+ """
12
+
13
+ import json
14
+ import sqlite3
15
+ import sys
16
+ from datetime import datetime, date, timedelta
17
+ from pathlib import Path
18
+
19
+ NEXO_DB = Path.home() / ".nexo" / "nexo-mcp" / "nexo.db"
20
+ COORD_DIR = Path.home() / ".nexo" / "coordination"
21
+ LOG_FILE = Path.home() / ".nexo" / "logs" / "followup-hygiene.log"
22
+
23
+ TODAY = date.today().isoformat()
24
+
25
+
26
+ def log(msg):
27
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
28
+ line = f"[{ts}] {msg}"
29
+ print(line, flush=True)
30
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
31
+ with open(LOG_FILE, "a") as f:
32
+ f.write(line + "\n")
33
+
34
+
35
+ def main():
36
+ log("=== Followup Hygiene starting ===")
37
+
38
+ if not NEXO_DB.exists():
39
+ log("nexo.db not found")
40
+ return
41
+
42
+ conn = sqlite3.connect(str(NEXO_DB))
43
+ conn.row_factory = sqlite3.Row
44
+
45
+ # 1. Normalize dirty statuses
46
+ dirty_f = conn.execute("SELECT COUNT(*) FROM followups WHERE status LIKE 'COMPLETED %'").fetchone()[0]
47
+ dirty_r = conn.execute("SELECT COUNT(*) FROM reminders WHERE status LIKE 'COMPLETED %'").fetchone()[0]
48
+
49
+ if dirty_f > 0:
50
+ conn.execute("UPDATE followups SET status='COMPLETED' WHERE status LIKE 'COMPLETED %'")
51
+ log(f"Normalized {dirty_f} dirty followup statuses")
52
+
53
+ if dirty_r > 0:
54
+ conn.execute("UPDATE reminders SET status='COMPLETED' WHERE status LIKE 'COMPLETED %'")
55
+ log(f"Normalized {dirty_r} dirty reminder statuses")
56
+
57
+ # 2. Flag stale followups (PENDING >14 days, no updates)
58
+ cutoff = (date.today() - timedelta(days=14)).isoformat()
59
+ stale = conn.execute(
60
+ "SELECT id, description, date, updated_at FROM followups "
61
+ "WHERE status NOT LIKE 'COMPLETED%' AND status NOT LIKE 'COMPLETED%' "
62
+ "AND date != '' AND date < ? "
63
+ "ORDER BY date",
64
+ (cutoff,)
65
+ ).fetchall()
66
+
67
+ if stale:
68
+ log(f"Found {len(stale)} stale followups (>14 days overdue):")
69
+ for s in stale[:10]:
70
+ log(f" {s['id']}: {s['description'][:60]} (due: {s['date']})")
71
+
72
+ # 3. Orphaned followups (no date, no recent update)
73
+ orphans = conn.execute(
74
+ "SELECT id, description FROM followups "
75
+ "WHERE status NOT LIKE 'COMPLETED%' AND status NOT LIKE 'COMPLETED%' "
76
+ "AND (date IS NULL OR date = '') "
77
+ "ORDER BY id"
78
+ ).fetchall()
79
+
80
+ if orphans:
81
+ log(f"Found {len(orphans)} orphaned followups (no date):")
82
+ for o in orphans[:10]:
83
+ log(f" {o['id']}: {o['description'][:60]}")
84
+
85
+ conn.commit()
86
+ conn.close()
87
+
88
+ # 4. Write summary for synthesis
89
+ summary = {
90
+ "date": TODAY,
91
+ "dirty_normalized": dirty_f + dirty_r,
92
+ "stale_count": len(stale) if stale else 0,
93
+ "orphan_count": len(orphans) if orphans else 0,
94
+ "stale_ids": [s["id"] for s in stale[:20]] if stale else [],
95
+ "orphan_ids": [o["id"] for o in orphans[:20]] if orphans else [],
96
+ }
97
+
98
+ summary_file = COORD_DIR / "followup-hygiene-summary.json"
99
+ summary_file.parent.mkdir(parents=True, exist_ok=True)
100
+ summary_file.write_text(json.dumps(summary, indent=2))
101
+
102
+ log(f"Summary: {dirty_f + dirty_r} normalized, {len(stale) if stale else 0} stale, {len(orphans) if orphans else 0} orphans")
103
+ log("=== Followup Hygiene complete ===")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
@@ -68,7 +68,7 @@ def collect_data() -> dict:
68
68
  conn = sqlite3.connect(str(NEXO_DB))
69
69
  conn.row_factory = sqlite3.Row
70
70
 
71
- # Diarios de hoy con autocrítica
71
+ # Today diaries with self-critique
72
72
  rows = conn.execute(
73
73
  "SELECT id, session_id, summary, self_critique, user_signals, "
74
74
  "mental_state, domain, created_at "
@@ -106,7 +106,7 @@ def consolidate_with_cli(data: dict) -> bool:
106
106
 
107
107
  diaries_with_critique = [
108
108
  d for d in data["diaries"]
109
- if d.get("self_critique") and not (d["self_critique"] or "").strip().lower().startswith("sin autocrítica")
109
+ if d.get("self_critique") and not (d["self_critique"] or "").strip().lower().startswith("no self-critique")
110
110
  ]
111
111
 
112
112
  if not diaries_with_critique:
@@ -118,13 +118,13 @@ def consolidate_with_cli(data: dict) -> bool:
118
118
  if len(diaries_json) > 12000:
119
119
  diaries_json = diaries_json[:12000] + "\n... (truncado)"
120
120
 
121
- prompt = f"""Eres el consolidador nocturno de NEXO. Tu trabajo es revisar las autocríticas
122
- del día y decidir cuáles merecen convertirse en reglas permanentes (feedback_postmortem_*.md).
121
+ prompt = f"""You are NEXO nightly consolidator. Your job is to review self-critiques
122
+ from today and decide which deserve to become permanent rules (feedback_postmortem_*.md).
123
123
 
124
124
  FECHA: {data['date']}
125
- SESIONES HOY: {len(data['diaries'])} total, {len(diaries_with_critique)} con autocrítica
125
+ SESSIONS TODAY: {len(data['diaries'])} total, {len(diaries_with_critique)} with self-critique
126
126
 
127
- DIARIOS CON AUTOCRÍTICA:
127
+ DIARIES WITH SELF-CRITIQUE:
128
128
  {diaries_json}
129
129
 
130
130
  FEEDBACKS POSTMORTEM QUE YA EXISTEN ({len(data['existing_feedbacks'])}):
@@ -138,44 +138,44 @@ INSTRUCCIONES:
138
138
  1. Lee cada self_critique y entiende su SIGNIFICADO (no cuentes palabras).
139
139
 
140
140
  2. PROMOVER a feedback permanente SOLO SI:
141
- - Un patrón aparece en 2+ sesiones diferentes del día (por significado, no texto literal)
141
+ - A pattern appears in 2+ different sessions today (by meaning, not literal text)
142
142
  - O the user corrigió explícitamente (user_signals contiene corrección)
143
- - Y la autocrítica contiene una ACCIÓN CONCRETA que prevenga un error futuro
143
+ - Y the self-critique contains a CONCRETE ACTION that prevents a future error
144
144
  - Y NO existe ya un feedback similar en los existentes
145
145
 
146
146
  3. NO promover si:
147
- - Es una respuesta negativa ("No pasó nada", "sesión limpia")
148
- - Es genérica sin acción concreta
149
- - Ya existe un feedback que cubre el mismo tema
147
+ - It is a negative response ("No pasó nada", "sesión limpia")
148
+ - It is generic without concrete action
149
+ - A feedback already exists covering the same topic
150
150
 
151
- 4. Para cada regla a promover, crea el archivo con Write en {MEMORY_DIR}/:
151
+ 4. For each rule to promote, create the file with Write en {MEMORY_DIR}/:
152
152
  Nombre: feedback_postmortem_[slug_descriptivo].md
153
153
  Formato:
154
154
  ---
155
- name: [título descriptivo]
156
- description: Regla de comportamiento extraída de autocrítica patrón recurrente
155
+ name: [descriptive title]
156
+ description: Behavioral rule extracted from self-critiquerecurring pattern
157
157
  type: feedback
158
158
  ---
159
159
 
160
- [Descripción clara del patrón y la regla]
160
+ [Clear description of the pattern and rule]
161
161
 
162
- **Why:** [Por qué esto importa con evidencia de las sesiones]
163
- **How to apply:** [Cuándo y cómo aplicar esta regla]
162
+ **Why:** [Why this matterswith evidence from sessions]
163
+ **How to apply:** [When and how to apply this rule]
164
164
 
165
- 5. Escribe el resumen diario en ~/.nexo/coordination/postmortem-daily.md:
165
+ 5. Write the daily summary en ~/.nexo/coordination/postmortem-daily.md:
166
166
  # Post-Mortem Daily — {data['date']}
167
- Sesiones: X | Autocríticas: Y | Promovidos: Z
167
+ Sessions: X | Self-critiques: Y | Promoted: Z
168
168
 
169
- ## Autocríticas del día (resumen)
170
- [Lista breve]
169
+ ## Self-critiques of the day (summary)
170
+ [Brief list]
171
171
 
172
172
  ## Promovido a memoria permanente
173
- [Lo que promoviste y por qué]
173
+ [What you promoted and why]
174
174
 
175
- ## Descartado (y por qué)
176
- [Lo que NO promoviste y la razón]
175
+ ## Discarded (and why)
176
+ [What you did NOT promote and why]
177
177
 
178
- Ejecuta sin preguntar."""
178
+ Execute without asking."""
179
179
 
180
180
  log(f"Stage 2: Invoking Claude CLI (opus) with {len(diaries_with_critique)} critiques...")
181
181
 
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env /Library/Frameworks/Python.framework/Versions/3.12/bin/python3
2
+ """
3
+ NEXO Pre-Commit Validator — Dynamic version
4
+ Queries nexo.db learnings to surface relevant warnings/blockers before commit.
5
+ Runs standalone (no MCP dependency, just sqlite3).
6
+ """
7
+ import sqlite3
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ NEXO_DB = Path.home() / ".nexo" / "nexo-mcp" / "nexo.db"
13
+
14
+
15
+ def get_staged_files():
16
+ """Get list of staged files."""
17
+ result = subprocess.run(
18
+ ['git', 'diff', '--cached', '--name-only'],
19
+ capture_output=True, text=True
20
+ )
21
+ return [f for f in result.stdout.strip().split('\n') if f]
22
+
23
+
24
+ def check_file(filepath, conn):
25
+ """Dynamic checks from learnings DB."""
26
+ errors, warnings = [], []
27
+
28
+ filename = Path(filepath).name
29
+ parent_dir = Path(filepath).parent.name
30
+
31
+ # 1. Find learnings mentioning this file or directory
32
+ file_learnings = conn.execute(
33
+ "SELECT id, title, content FROM learnings WHERE INSTR(content, ?) > 0 OR INSTR(content, ?) > 0",
34
+ (filename, parent_dir)
35
+ ).fetchall()
36
+
37
+ # 2. Check repetition count for each matching learning
38
+ for row in file_learnings:
39
+ lid, title = row[0], row[1]
40
+ rep_count = conn.execute(
41
+ "SELECT COUNT(*) FROM error_repetitions WHERE original_learning_id = ?",
42
+ (lid,)
43
+ ).fetchone()[0]
44
+
45
+ if rep_count >= 5:
46
+ errors.append(f"BLOCKED #{lid} ({rep_count}x repeated): {title[:80]}")
47
+ elif rep_count >= 3:
48
+ warnings.append(f"WARNING #{lid} ({rep_count}x repeated): {title[:80]}")
49
+
50
+ # 3. Universal rules (SIEMPRE/NUNCA/ANTES) matching this file
51
+ universal = conn.execute(
52
+ "SELECT id, title, content FROM learnings WHERE "
53
+ "(content LIKE '%SIEMPRE%' OR content LIKE '%NUNCA%' OR content LIKE '%ANTES%') "
54
+ "AND (INSTR(content, ?) > 0 OR INSTR(content, ?) > 0)",
55
+ (filename, parent_dir)
56
+ ).fetchall()
57
+
58
+ for row in universal:
59
+ lid, title = row[0], row[1]
60
+ # Don't duplicate if already found above
61
+ if not any(f"#{lid}" in e for e in errors + warnings):
62
+ warnings.append(f"RULE #{lid}: {title[:80]}")
63
+
64
+ return errors, warnings
65
+
66
+
67
+ def main():
68
+ """Run pre-commit validation."""
69
+ staged = get_staged_files()
70
+ if not staged:
71
+ print("No staged files to check")
72
+ return 0
73
+
74
+ if not NEXO_DB.exists():
75
+ print("nexo.db not found — skipping dynamic checks")
76
+ return 0
77
+
78
+ conn = sqlite3.connect(str(NEXO_DB), timeout=5)
79
+
80
+ all_errors = {}
81
+ all_warnings = {}
82
+
83
+ for filepath in staged:
84
+ errors, warnings = check_file(filepath, conn)
85
+ if errors:
86
+ all_errors[filepath] = errors
87
+ if warnings:
88
+ all_warnings[filepath] = warnings
89
+
90
+ conn.close()
91
+
92
+ # Print warnings (non-blocking)
93
+ if all_warnings:
94
+ print("\nWARNINGS:")
95
+ for filepath, warns in all_warnings.items():
96
+ print(f"\n {filepath}:")
97
+ for w in warns:
98
+ print(f" - {w}")
99
+
100
+ # Print errors (blocking)
101
+ if all_errors:
102
+ print("\nBLOCKED — Fix these before committing:\n")
103
+ for filepath, errs in all_errors.items():
104
+ print(f" {filepath}:")
105
+ for e in errs:
106
+ print(f" - {e}")
107
+ print()
108
+ return 1
109
+
110
+ if all_warnings:
111
+ print("\nPre-commit passed with warnings\n")
112
+ else:
113
+ print("Pre-commit validation passed")
114
+ return 0
115
+
116
+
117
+ if __name__ == '__main__':
118
+ sys.exit(main())