nexo-brain 1.2.3 → 1.4.0

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 (29) hide show
  1. package/README.md +10 -5
  2. package/package.json +1 -1
  3. package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
  4. package/src/cognitive.py +45 -0
  5. package/src/evolution_cycle.py +266 -0
  6. package/src/plugins/guard.py +235 -1
  7. package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
  8. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  9. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  10. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  11. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  12. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  13. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  14. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  15. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  16. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  17. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  18. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  19. package/src/scripts/check-context.py +257 -0
  20. package/src/scripts/nexo-catchup.py +59 -5
  21. package/src/scripts/nexo-cognitive-decay.py +8 -0
  22. package/src/scripts/nexo-daily-self-audit.py +168 -183
  23. package/src/scripts/nexo-evolution-run.py +584 -0
  24. package/src/scripts/nexo-immune.py +108 -91
  25. package/src/scripts/nexo-learning-validator.py +226 -0
  26. package/src/scripts/nexo-postmortem-consolidator.py +230 -414
  27. package/src/scripts/nexo-sleep.py +283 -503
  28. package/src/scripts/nexo-synthesis.py +141 -432
  29. package/src/tools_sessions.py +20 -12
@@ -1,55 +1,46 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- NEXO Post-Mortem Consolidator — Daily behavioral learning extraction.
3
+ NEXO Post-Mortem Consolidator v2 The brain consolidates memories.
4
4
 
5
- Runs daily at 23:30 via LaunchAgent. Reads all session diaries from today,
6
- extracts self_critique entries, identifies RECURRING behavioral patterns,
7
- and writes permanent rules to memory files so they survive forever.
5
+ Before: 595 lines of word-overlap al 50% para detectar "patrones".
6
+ Now: Collects data, passes them to CLI which UNDERSTANDS what it reads.
8
7
 
9
- Three layers:
10
- 1. Session self_critique field in session_diary (captured at session end)
11
- 2. Daily → this script consolidates all critiques from today
12
- 3. Permanent → writes to feedback_*.md files + MEMORY.md index
8
+ Runs daily at 23:30 via LaunchAgent. Reads session diaries from today,
9
+ passes them to Claude CLI (opus) which decides what deserves permanent memory.
13
10
 
14
- Only creates permanent memory for patterns that:
15
- - Appear 2+ times in the same day, OR
16
- - Appear in 3+ different days (checked against history), OR
17
- - User explicitly corrected NEXO (user_signals contains correction keywords)
11
+ Stage 1 Data collection (pure Python):
12
+ Query session diaries, existing feedbacks, history.
13
+
14
+ Stage 2 Intelligence (Claude CLI opus):
15
+ Read diaries, understand patterns, decide what to promote.
16
+
17
+ Stage 3 — Sensory Register + Force analysis (pure Python):
18
+ Process cognitive events. Kept from v1 — genuinely mechanical.
18
19
  """
19
20
 
20
21
  import json
21
22
  import os
22
- import re
23
23
  import sqlite3
24
+ import subprocess
24
25
  import sys
25
- from collections import Counter
26
26
  from datetime import datetime, date, timedelta
27
27
  from pathlib import Path
28
28
 
29
- # Add NEXO_HOME to path for cognitive engine
30
- NEXO_HOME = os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))
31
- sys.path.insert(0, NEXO_HOME)
32
- # Fallback for development installs
33
- sys.path.insert(0, str(Path.home() / "claude" / "nexo-mcp"))
29
+ # Add nexo to path for cognitive engine (Stage 3)
30
+ sys.path.insert(0, str(Path.home() / ".nexo"))
34
31
 
35
32
  HOME = Path.home()
36
- NEXO_DB = Path(NEXO_HOME) / "nexo.db"
37
- SESSION_BUFFER = Path(NEXO_HOME) / "brain" / "session_buffer.jsonl"
38
- MEMORY_DIR = HOME / ".claude" / "projects" / f"-Users-{os.environ.get('USER', 'user')}" / "memory"
33
+ NEXO_DB = HOME / ".nexo" / "nexo.db"
34
+ MEMORY_DIR = HOME / ".nexo" / "memory"
39
35
  MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
40
- CONSOLIDATION_LOG = HOME / "claude" / "logs" / "postmortem-consolidation.log"
41
- HISTORY_FILE = HOME / "claude" / "coordination" / "postmortem-history.json"
36
+ HISTORY_FILE = HOME / ".nexo" / "coordination" / "postmortem-history.json"
37
+ CONSOLIDATION_LOG = HOME / ".nexo" / "logs" / "postmortem-consolidation.log"
38
+ CLAUDE_CLI = HOME / ".local" / "bin" / "claude"
39
+ SESSION_BUFFER = HOME / ".nexo" / "brain" / "session_buffer.jsonl"
42
40
 
43
41
  TODAY = date.today()
44
42
  TODAY_STR = TODAY.isoformat()
45
43
 
46
- CORRECTION_KEYWORDS = [
47
- "corrig", "frustrat", "don't understand", "demand", "repeat",
48
- "shouldn't", "why not", "again", "already told you",
49
- "tiring", "always wait", "not proactive", "reactive",
50
- "don't do", "error", "wrong", "failure", "irritat"
51
- ]
52
-
53
44
 
54
45
  def log(msg: str):
55
46
  ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -60,152 +51,184 @@ def log(msg: str):
60
51
  f.write(line + "\n")
61
52
 
62
53
 
63
- def get_today_diaries() -> list[dict]:
64
- """Get all session diaries from today with self_critique."""
54
+ # ─── Stage 1: Data Collection (pure Python) ─────────────────────────────────
55
+
56
+ def collect_data() -> dict:
57
+ """Collect all data the CLI needs to make decisions."""
58
+ data = {
59
+ "date": TODAY_STR,
60
+ "diaries": [],
61
+ "existing_feedbacks": [],
62
+ "history_summary": {},
63
+ }
64
+
65
65
  if not NEXO_DB.exists():
66
- return []
66
+ return data
67
+
67
68
  conn = sqlite3.connect(str(NEXO_DB))
68
69
  conn.row_factory = sqlite3.Row
70
+
71
+ # Diarios de hoy con autocrítica
69
72
  rows = conn.execute(
70
- "SELECT id, session_id, summary, self_critique, user_signals, mental_state, domain, created_at "
73
+ "SELECT id, session_id, summary, self_critique, user_signals, "
74
+ "mental_state, domain, created_at "
71
75
  "FROM session_diary WHERE date(created_at) = ? ORDER BY created_at",
72
76
  (TODAY_STR,)
73
77
  ).fetchall()
78
+ data["diaries"] = [dict(r) for r in rows]
79
+
74
80
  conn.close()
75
- return [dict(r) for r in rows]
76
81
 
82
+ # Feedbacks postmortem existentes (nombres, para no duplicar)
83
+ data["existing_feedbacks"] = [
84
+ f.stem for f in MEMORY_DIR.glob("feedback_postmortem_*.md")
85
+ ]
77
86
 
78
- def get_historical_critiques(days: int = 30) -> list[dict]:
79
- """Get self_critique from the last N days for pattern detection."""
80
- if not NEXO_DB.exists():
81
- return []
82
- since = (TODAY - timedelta(days=days)).isoformat()
83
- conn = sqlite3.connect(str(NEXO_DB))
84
- conn.row_factory = sqlite3.Row
85
- rows = conn.execute(
86
- "SELECT self_critique, user_signals, created_at "
87
- "FROM session_diary WHERE date(created_at) >= ? AND self_critique != '' "
88
- "ORDER BY created_at",
89
- (since,)
90
- ).fetchall()
91
- conn.close()
92
- return [dict(r) for r in rows]
87
+ # Resumen del historial
88
+ if HISTORY_FILE.exists():
89
+ try:
90
+ history = json.loads(HISTORY_FILE.read_text())
91
+ data["history_summary"] = {
92
+ "total_permanent_rules": len(history.get("permanent_rules", [])),
93
+ "days_tracked": len(history.get("days", {})),
94
+ "recent_rules": history.get("permanent_rules", [])[-10:],
95
+ }
96
+ except Exception:
97
+ pass
93
98
 
99
+ return data
94
100
 
95
- def has_correction_signals(signals: str) -> bool:
96
- """Check if user_signals indicate corrections."""
97
- if not signals:
98
- return False
99
- lower = signals.lower()
100
- return any(kw in lower for kw in CORRECTION_KEYWORDS)
101
101
 
102
+ # ─── Stage 2: Intelligence (Claude CLI opus) ────────────────────────────────
102
103
 
103
- def extract_actionable_rules(critiques: list[str]) -> list[str]:
104
- """Extract concrete, actionable rules from self-critique text."""
105
- rules = []
106
- for critique in critiques:
107
- if not critique or critique.strip().lower().startswith("no self-critique"):
108
- continue
109
- # Each non-empty critique is a potential rule
110
- # Clean up and normalize
111
- for line in critique.split("\n"):
112
- line = line.strip().lstrip("- ").strip()
113
- if len(line) > 20 and not line.lower().startswith("no "):
114
- rules.append(line)
115
- return rules
104
+ def consolidate_with_cli(data: dict) -> bool:
105
+ """El cerebro consolida CLI decide qué promover."""
116
106
 
107
+ diaries_with_critique = [
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")
110
+ ]
117
111
 
118
- def load_history() -> dict:
119
- """Load consolidation history to detect recurring patterns."""
120
- if HISTORY_FILE.exists():
121
- try:
122
- return json.loads(HISTORY_FILE.read_text())
123
- except Exception:
124
- return {"days": {}, "permanent_rules": []}
125
- return {"days": {}, "permanent_rules": []}
126
-
127
-
128
- def save_history(history: dict):
129
- """Save consolidation history."""
130
- HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
131
- # Keep last 90 days
132
- cutoff = (TODAY - timedelta(days=90)).isoformat()
133
- history["days"] = {k: v for k, v in history["days"].items() if k >= cutoff}
134
- HISTORY_FILE.write_text(json.dumps(history, ensure_ascii=False, indent=2))
135
-
136
-
137
- def rule_already_permanent(rule: str, history: dict) -> bool:
138
- """Check if a similar rule is already in permanent memory."""
139
- rule_lower = rule.lower()
140
- for existing in history.get("permanent_rules", []):
141
- # Simple similarity: if >60% of words overlap
142
- existing_words = set(existing.lower().split())
143
- rule_words = set(rule_lower.split())
144
- if not rule_words:
145
- return True
146
- overlap = len(existing_words & rule_words) / len(rule_words)
147
- if overlap > 0.6:
148
- return True
149
- return False
112
+ if not diaries_with_critique:
113
+ log("All sessions clean or trivial. Nothing to consolidate.")
114
+ return True
150
115
 
116
+ # Preparar datos para el CLI (truncar para no exceder contexto)
117
+ diaries_json = json.dumps(diaries_with_critique, ensure_ascii=False, indent=1)
118
+ if len(diaries_json) > 12000:
119
+ diaries_json = diaries_json[:12000] + "\n... (truncado)"
151
120
 
152
- def write_permanent_rule(rule_title: str, rule_content: str, source_critiques: list[str]):
153
- """Write a new permanent feedback memory file."""
154
- # Generate filename
155
- slug = re.sub(r'[^a-z0-9]+', '_', rule_title.lower())[:50].strip('_')
156
- filename = f"feedback_postmortem_{slug}.md"
157
- filepath = MEMORY_DIR / filename
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).
158
123
 
159
- if filepath.exists():
160
- log(f" File already exists: {filename}, skipping")
161
- return None
124
+ FECHA: {data['date']}
125
+ SESIONES HOY: {len(data['diaries'])} total, {len(diaries_with_critique)} con autocrítica
162
126
 
163
- content = f"""---
164
- name: {rule_title}
165
- description: Behavioral rule extracted from post-mortem self-critique — recurring pattern detected
166
- type: feedback
167
- ---
127
+ DIARIOS CON AUTOCRÍTICA:
128
+ {diaries_json}
168
129
 
169
- {rule_content}
130
+ FEEDBACKS POSTMORTEM QUE YA EXISTEN ({len(data['existing_feedbacks'])}):
131
+ {json.dumps(data['existing_feedbacks'][:30], ensure_ascii=False)}
170
132
 
171
- **Why:** Pattern detected across multiple sessions where NEXO failed in this regard. The user should not have to correct the same thing twice.
133
+ REGLAS PERMANENTES RECIENTES:
134
+ {json.dumps(data['history_summary'].get('recent_rules', []), ensure_ascii=False)}
172
135
 
173
- **How to apply:** Verify this rule at the start of each session and before presenting work as complete.
136
+ INSTRUCCIONES:
174
137
 
175
- **Evidence (original self-critiques):**
176
- """
177
- for i, critique in enumerate(source_critiques[:3], 1):
178
- content += f"- Session {i}: {critique[:200]}\n"
138
+ 1. Lee cada self_critique y entiende su SIGNIFICADO (no cuentes palabras).
139
+
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)
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
144
+ - Y NO existe ya un feedback similar en los existentes
145
+
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
150
+
151
+ 4. Para cada regla a promover, crea el archivo con Write en {MEMORY_DIR}/:
152
+ Nombre: feedback_postmortem_[slug_descriptivo].md
153
+ Formato:
154
+ ---
155
+ name: [título descriptivo]
156
+ description: Regla de comportamiento extraída de autocrítica — patrón recurrente
157
+ type: feedback
158
+ ---
159
+
160
+ [Descripción clara del patrón y la regla]
161
+
162
+ **Why:** [Por qué esto importa — con evidencia de las sesiones]
163
+ **How to apply:** [Cuándo y cómo aplicar esta regla]
164
+
165
+ 5. Escribe el resumen diario en ~/.nexo/coordination/postmortem-daily.md:
166
+ # Post-Mortem Daily — {data['date']}
167
+ Sesiones: X | Autocríticas: Y | Promovidos: Z
168
+
169
+ ## Autocríticas del día (resumen)
170
+ [Lista breve]
171
+
172
+ ## Promovido a memoria permanente
173
+ [Lo que promoviste y por qué]
179
174
 
180
- filepath.write_text(content)
181
- log(f" Written permanent rule: {filename}")
182
- return filename
175
+ ## Descartado (y por qué)
176
+ [Lo que NO promoviste y la razón]
183
177
 
178
+ Ejecuta sin preguntar."""
179
+
180
+ log(f"Stage 2: Invoking Claude CLI (opus) with {len(diaries_with_critique)} critiques...")
181
+
182
+ env = os.environ.copy()
183
+ env.pop("CLAUDECODE", None)
184
+ env.pop("CLAUDE_CODE", None)
185
+
186
+ try:
187
+ result = subprocess.run(
188
+ [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
189
+ "--allowedTools", "Read,Write,Edit,Glob,Grep"],
190
+ capture_output=True, text=True, timeout=300, env=env
191
+ )
192
+
193
+ if result.returncode != 0:
194
+ log(f"Stage 2: CLI error (code {result.returncode}): {(result.stderr or '')[:300]}")
195
+ return False
196
+
197
+ log(f"Stage 2: Completed. Output: {len(result.stdout or '')} chars")
198
+ # Log last 500 chars of output for debugging
199
+ if result.stdout:
200
+ log(f"Stage 2 output tail: {result.stdout[-500:]}")
201
+ return True
202
+
203
+ except subprocess.TimeoutExpired:
204
+ log("Stage 2: CLI timed out (300s)")
205
+ return False
206
+ except Exception as e:
207
+ log(f"Stage 2: Exception: {e}")
208
+ return False
209
+
210
+
211
+ # ─── Stage 3: Sensory Register + Force Analysis (pure Python) ───────────────
212
+ # Kept from v1 — these are genuinely mechanical (embedding vectors, DB updates)
184
213
 
185
214
  def process_sensory_register():
186
- """
187
- Sensory Register — Atkinson-Shiffrin Layer 1.
188
- Reads today's session_buffer events, embeds them, compares against LTM
189
- to detect recurring patterns. Ingests meaningful events into STM as 'sensory'.
190
- """
215
+ """Sensory Register — Atkinson-Shiffrin Layer 1. Embeds events into STM."""
191
216
  log("--- Sensory Register processing ---")
192
217
 
193
218
  if not SESSION_BUFFER.exists():
194
- log(" No session_buffer.jsonl found, skipping sensory processing")
219
+ log(" No session_buffer.jsonl found, skipping")
195
220
  return
196
221
 
197
- # Read today's events from session_buffer
198
222
  today_events = []
199
223
  try:
200
- with open(SESSION_BUFFER, "r") as f:
224
+ with open(SESSION_BUFFER) as f:
201
225
  for line in f:
202
226
  line = line.strip()
203
227
  if not line:
204
228
  continue
205
229
  try:
206
230
  event = json.loads(line)
207
- ts = event.get("ts", "")
208
- if ts.startswith(TODAY_STR):
231
+ if event.get("ts", "").startswith(TODAY_STR):
209
232
  today_events.append(event)
210
233
  except json.JSONDecodeError:
211
234
  continue
@@ -214,47 +237,33 @@ def process_sensory_register():
214
237
  return
215
238
 
216
239
  if not today_events:
217
- log(" No events from today in session_buffer")
240
+ log(" No events from today")
218
241
  return
219
242
 
220
- log(f" Found {len(today_events)} events from today")
243
+ log(f" Found {len(today_events)} events")
221
244
 
222
- # Import cognitive engine
223
245
  try:
224
246
  import cognitive
225
247
  except ImportError as e:
226
- log(f" Cannot import cognitive engine: {e}")
248
+ log(f" Cannot import cognitive: {e}")
227
249
  return
228
250
 
229
- # Process events — only embed meaningful ones (not hook-fallback noise)
230
251
  ingested = 0
231
- pattern_flags = []
232
-
233
252
  for event in today_events:
234
- tasks = event.get("tasks", [])
235
- decisions = event.get("decisions", [])
236
- errors = event.get("errors_resolved", [])
237
- user_patterns = event.get("user_patterns", [])
238
- critique = event.get("self_critique", "")
239
253
  source = event.get("source", "")
240
-
241
- # Skip empty hook-fallback events
242
- if source == "hook-fallback" and not decisions and not errors and not user_patterns:
243
- # Still embed if there are meaningful tasks (not just tool lists)
244
- task_str = " ".join(tasks) if tasks else ""
245
- if len(task_str) < 50 or "," in task_str: # tool lists have commas
254
+ if source == "hook-fallback":
255
+ task_str = " ".join(event.get("tasks", []))
256
+ if len(task_str) < 50 or "," in task_str:
246
257
  continue
247
258
 
248
- # Build content for embedding
249
259
  parts = []
250
- if tasks:
251
- parts.append(f"Tasks: {'; '.join(tasks[:5])}")
252
- if decisions:
253
- parts.append(f"Decisions: {'; '.join(str(d) for d in decisions[:3])}")
254
- if errors:
255
- parts.append(f"Errors resolved: {'; '.join(str(e) for e in errors[:3])}")
256
- if user_patterns:
257
- parts.append(f"User patterns: {'; '.join(str(p) for p in user_patterns[:3])}")
260
+ for key, label in [("tasks", "Tasks"), ("decisions", "Decisions"),
261
+ ("errors_resolved", "Errors"), ("the user_patterns", "user")]:
262
+ val = event.get(key, [])
263
+ if val:
264
+ parts.append(f"{label}: {'; '.join(str(v) for v in val[:3])}")
265
+
266
+ critique = event.get("self_critique", "")
258
267
  if critique and "hook-fallback" not in critique:
259
268
  parts.append(f"Self-critique: {critique[:200]}")
260
269
 
@@ -262,131 +271,42 @@ def process_sensory_register():
262
271
  if not content or len(content) < 20:
263
272
  continue
264
273
 
265
- # Embed and check against LTM for patterns
266
274
  try:
267
275
  vec = cognitive.embed(content)
268
- patterns = cognitive.detect_patterns(vec, threshold=0.65)
269
-
270
- if patterns:
271
- pattern_flags.append({
272
- "event_ts": event.get("ts", ""),
273
- "content": content[:200],
274
- "matches": patterns[:3],
275
- })
276
-
277
- # Ingest into STM as sensory
278
- # Customize domain keywords for your own projects
279
276
  domain = ""
280
- if any(w in content.lower() for w in ["nexo", "cognitive", "guard"]):
281
- domain = "nexo"
282
- # Add your own project domains here:
283
- # elif any(w in content.lower() for w in ["myproject", "keyword"]):
284
- # domain = "myproject"
277
+ lower = content.lower()
278
+ for keyword, dom in [("nexo", "nexo"),
279
+ ("default", "general")]:
280
+ if keyword in lower:
281
+ domain = dom
282
+ break
285
283
 
286
284
  cognitive.ingest_sensory(
287
- content=content,
288
- source_id=f"buffer#{event.get('ts', '')}",
289
- domain=domain,
290
- created_at=event.get("ts", "")
285
+ content=content, source_id=f"buffer#{event.get('ts', '')}",
286
+ domain=domain, created_at=event.get("ts", "")
291
287
  )
292
288
  ingested += 1
293
289
  except Exception as e:
294
- log(f" Error embedding event: {e}")
295
- continue
290
+ log(f" Error embedding: {e}")
296
291
 
297
292
  log(f" Ingested {ingested} sensory events into STM")
298
293
 
299
- # Report pattern matches (potential recurring behaviors)
300
- if pattern_flags:
301
- log(f" PATTERN ALERT: {len(pattern_flags)} events matched existing LTM memories (potential repetitions)")
302
- for pf in pattern_flags[:5]:
303
- best = pf["matches"][0]
304
- log(f" [{best['score']:.2f}] Event: {pf['content'][:80]}...")
305
- log(f" Matches LTM {best['source_type']}: {best['content'][:80]}...")
306
-
307
- # Archive: compress old events from buffer (>48h)
308
- archive_sensory_buffer()
309
-
310
- return {"ingested": ingested, "patterns": len(pattern_flags)}
311
-
312
-
313
- def archive_sensory_buffer():
314
- """Move events older than 48h from session_buffer to daily archive files."""
315
- if not SESSION_BUFFER.exists():
316
- return
317
-
318
- cutoff = (datetime.now() - timedelta(hours=48)).isoformat()
319
- archive_dir = HOME / "claude" / "brain" / "session_archive"
320
- archive_dir.mkdir(parents=True, exist_ok=True)
321
-
322
- keep_lines = []
323
- archived_by_day = {}
324
-
325
- try:
326
- with open(SESSION_BUFFER, "r") as f:
327
- for line in f:
328
- stripped = line.strip()
329
- if not stripped:
330
- continue
331
- try:
332
- event = json.loads(stripped)
333
- ts = event.get("ts", "")
334
- if ts < cutoff:
335
- day = ts[:10]
336
- archived_by_day.setdefault(day, []).append(stripped)
337
- else:
338
- keep_lines.append(stripped)
339
- except json.JSONDecodeError:
340
- keep_lines.append(stripped)
341
-
342
- # Write archived events to daily files
343
- total_archived = 0
344
- for day, lines in archived_by_day.items():
345
- archive_file = archive_dir / f"{day}.jsonl"
346
- with open(archive_file, "a") as f:
347
- for line in lines:
348
- f.write(line + "\n")
349
- total_archived += len(lines)
350
-
351
- # Rewrite buffer with only recent events
352
- if total_archived > 0:
353
- with open(SESSION_BUFFER, "w") as f:
354
- for line in keep_lines:
355
- f.write(line + "\n")
356
- log(f" Archived {total_archived} events (>48h) to session_archive/")
357
- else:
358
- log(f" No events to archive (all within 48h)")
359
-
360
- except Exception as e:
361
- log(f" Error archiving sensory buffer: {e}")
362
-
363
294
 
364
295
  def analyze_force_events():
365
- """Analyze --force dissonance resolutions from today.
366
-
367
- When the user uses --force, NEXO obeyed without discussion. The nocturnal
368
- process must now ask: was the old memory wrong, or was the user taking
369
- conscious technical debt?
370
-
371
- If a --force exception targets the same memory multiple times → it's probably
372
- a paradigm shift, not an exception. Flag for morning review.
373
- """
296
+ """Analyze --force dissonance resolutions from today."""
374
297
  log("--- Force event analysis ---")
375
298
 
376
299
  try:
377
300
  import cognitive
378
301
  except ImportError:
379
- log(" Cannot import cognitive engine, skipping")
302
+ log(" Cannot import cognitive, skipping")
380
303
  return
381
304
 
382
305
  db = cognitive._get_db()
383
306
  today_forces = db.execute(
384
- """SELECT memory_id, context, created_at
385
- FROM memory_corrections
386
- WHERE correction_type = 'exception'
387
- AND context LIKE '%[FORCE]%'
388
- AND date(created_at) = ?
389
- ORDER BY created_at""",
307
+ """SELECT memory_id, context, created_at FROM memory_corrections
308
+ WHERE correction_type = 'exception' AND context LIKE '%[FORCE]%'
309
+ AND date(created_at) = ? ORDER BY created_at""",
390
310
  (TODAY_STR,)
391
311
  ).fetchall()
392
312
 
@@ -394,201 +314,97 @@ def analyze_force_events():
394
314
  log(" No --force events today")
395
315
  return
396
316
 
397
- log(f" {len(today_forces)} --force events today")
317
+ log(f" {len(today_forces)} --force events")
398
318
 
399
- # Count how many times each memory was force-overridden
400
319
  from collections import Counter
401
320
  memory_counts = Counter(r["memory_id"] for r in today_forces)
402
-
403
321
  for mem_id, count in memory_counts.most_common():
404
322
  mem = db.execute(
405
- "SELECT content, source_type, strength, domain FROM ltm_memories WHERE id = ?",
406
- (mem_id,)
323
+ "SELECT content, strength FROM ltm_memories WHERE id = ?", (mem_id,)
407
324
  ).fetchone()
408
325
  if not mem:
409
326
  continue
410
327
 
411
- # Check total force-overrides for this memory (all time)
412
- total_overrides = db.execute(
328
+ total = db.execute(
413
329
  "SELECT COUNT(*) FROM memory_corrections WHERE memory_id = ? AND context LIKE '%[FORCE]%'",
414
330
  (mem_id,)
415
331
  ).fetchone()[0]
416
332
 
417
- if total_overrides >= 3:
418
- log(f" PARADIGM SHIFT CANDIDATE: LTM #{mem_id} force-overridden {total_overrides}x total")
419
- log(f" Content: {mem['content'][:120]}")
420
- log(f" Action: Decaying strength from {mem['strength']:.2f} to 0.3")
421
- # Auto-decay — if it's been overridden 3+ times, User clearly disagrees
333
+ if total >= 3:
334
+ log(f" PARADIGM SHIFT: LTM #{mem_id} overridden {total}x → decay to 0.3")
422
335
  db.execute(
423
- "UPDATE ltm_memories SET strength = 0.3, tags = CASE WHEN tags LIKE '%paradigm_candidate%' THEN tags ELSE tags || ',paradigm_candidate' END WHERE id = ?",
336
+ "UPDATE ltm_memories SET strength = 0.3, "
337
+ "tags = CASE WHEN tags LIKE '%paradigm_candidate%' THEN tags "
338
+ "ELSE tags || ',paradigm_candidate' END WHERE id = ?",
424
339
  (mem_id,)
425
340
  )
426
341
  elif count >= 2:
427
- log(f" WATCH: LTM #{mem_id} force-overridden {count}x today ({total_overrides}x total)")
428
- log(f" Content: {mem['content'][:120]}")
429
- else:
430
- log(f" OK: LTM #{mem_id} force-overridden once (total: {total_overrides})")
342
+ log(f" WATCH: LTM #{mem_id} overridden {count}x today")
431
343
 
432
344
  db.commit()
433
345
 
434
346
 
435
- def main():
436
- log("=== NEXO Post-Mortem Consolidator starting ===")
437
-
438
- diaries = get_today_diaries()
439
- if not diaries:
440
- log("No session diaries today. Nothing to consolidate.")
441
- return
442
-
443
- log(f"Found {len(diaries)} session diaries today.")
444
-
445
- # Collect critiques and signals
446
- today_critiques = []
447
- correction_critiques = []
347
+ # ─── Main ────────────────────────────────────────────────────────────────────
448
348
 
449
- for d in diaries:
450
- critique = d.get("self_critique") or ""
451
- signals = d.get("user_signals") or ""
349
+ def already_ran_today() -> bool:
350
+ """Prevent running twice on the same day."""
351
+ marker = HOME / ".nexo" / "coordination" / "postmortem-last-run"
352
+ if marker.exists():
353
+ try:
354
+ return marker.read_text().strip() == TODAY_STR
355
+ except Exception:
356
+ return False
357
+ return False
452
358
 
453
- if critique and not critique.strip().lower().startswith("no self-critique"):
454
- today_critiques.append(critique)
455
359
 
456
- if has_correction_signals(signals):
457
- correction_critiques.append({
458
- "critique": critique,
459
- "signals": signals,
460
- "domain": d.get("domain", ""),
461
- })
360
+ def mark_done():
361
+ marker = HOME / ".nexo" / "coordination" / "postmortem-last-run"
362
+ marker.parent.mkdir(parents=True, exist_ok=True)
363
+ marker.write_text(TODAY_STR)
462
364
 
463
- log(f" {len(today_critiques)} non-trivial critiques, {len(correction_critiques)} with correction signals")
464
365
 
465
- if not today_critiques and not correction_critiques:
466
- log("All sessions clean. Nothing to consolidate.")
366
+ def main():
367
+ if already_ran_today():
368
+ log("Already ran today. Skipping.")
467
369
  return
468
370
 
469
- # Load history
470
- history = load_history()
471
-
472
- # Save today's rules to history
473
- today_rules = extract_actionable_rules(today_critiques)
474
- history["days"][TODAY_STR] = {
475
- "rules": today_rules,
476
- "corrections": len(correction_critiques),
477
- "total_sessions": len(diaries),
478
- }
371
+ log("=== NEXO Post-Mortem Consolidator v2 starting ===")
479
372
 
480
- # Detect patterns that should become permanent
481
- new_permanent = []
373
+ # Stage 1: Collect data
374
+ data = collect_data()
375
+ log(f"Stage 1: {len(data['diaries'])} diaries, {len(data['existing_feedbacks'])} existing feedbacks")
482
376
 
483
- # Pattern 1: Same critique appears 2+ times TODAY
484
- if len(today_rules) >= 2:
485
- # Simple word-bag similarity between rules
486
- for i, rule in enumerate(today_rules):
487
- for j, other in enumerate(today_rules):
488
- if i >= j:
489
- continue
490
- words_i = set(rule.lower().split())
491
- words_j = set(other.lower().split())
492
- if words_i and words_j:
493
- overlap = len(words_i & words_j) / min(len(words_i), len(words_j))
494
- if overlap > 0.5 and not rule_already_permanent(rule, history):
495
- new_permanent.append({
496
- "title": f"Repeated pattern: {rule[:60]}",
497
- "content": f"Detected 2+ times on the same day:\n- {rule}\n- {other}",
498
- "sources": [rule, other],
499
- })
500
-
501
- # Pattern 2: Rule appears across 3+ different days
502
- all_historical_rules = []
503
- for day_str, day_data in history["days"].items():
504
- if day_str == TODAY_STR:
505
- continue
506
- for rule in day_data.get("rules", []):
507
- all_historical_rules.append((day_str, rule))
508
-
509
- for today_rule in today_rules:
510
- matching_days = set()
511
- today_words = set(today_rule.lower().split())
512
- for hist_day, hist_rule in all_historical_rules:
513
- hist_words = set(hist_rule.lower().split())
514
- if today_words and hist_words:
515
- overlap = len(today_words & hist_words) / min(len(today_words), len(hist_words))
516
- if overlap > 0.4:
517
- matching_days.add(hist_day)
518
-
519
- if len(matching_days) >= 2 and not rule_already_permanent(today_rule, history): # 2 historical + today = 3
520
- new_permanent.append({
521
- "title": f"Recurring pattern ({len(matching_days)+1} days): {today_rule[:50]}",
522
- "content": f"Detected across {len(matching_days)+1} different days:\n- Today: {today_rule}\n- Previous days: {', '.join(sorted(matching_days)[:5])}",
523
- "sources": [today_rule],
524
- })
525
-
526
- # Pattern 3: User corrected AND there's a critique → always promote
527
- for cc in correction_critiques:
528
- critique = cc.get("critique", "")
529
- if critique and not rule_already_permanent(critique, history):
530
- new_permanent.append({
531
- "title": f"User correction: {critique[:50]}",
532
- "content": f"The user explicitly corrected this behavior.\nSignals: {cc['signals'][:200]}\nSelf-critique: {critique[:300]}",
533
- "sources": [critique],
534
- })
535
-
536
- # Write permanent rules
537
- if new_permanent:
538
- log(f"Promoting {len(new_permanent)} patterns to permanent memory:")
539
- for rule in new_permanent:
540
- filename = write_permanent_rule(rule["title"], rule["content"], rule["sources"])
541
- if filename:
542
- history.setdefault("permanent_rules", []).append(rule["title"])
543
- else:
544
- log("No patterns qualify for permanent promotion today.")
545
-
546
- # Write daily summary to synthesis
547
- summary_file = HOME / "claude" / "coordination" / "postmortem-daily.md"
548
- summary_lines = [
549
- f"# Post-Mortem Daily — {TODAY_STR}",
550
- f"Sessions: {len(diaries)} | Self-critiques: {len(today_critiques)} | User corrections: {len(correction_critiques)}",
551
- "",
552
- ]
553
- if today_critiques:
554
- summary_lines.append("## Today's self-critiques")
555
- for c in today_critiques:
556
- summary_lines.append(f"- {c[:200]}")
557
- summary_lines.append("")
558
- if new_permanent:
559
- summary_lines.append("## Promoted to permanent memory")
560
- for r in new_permanent:
561
- summary_lines.append(f"- {r['title']}")
377
+ if not data["diaries"]:
378
+ log("No session diaries today. Nothing to consolidate.")
562
379
  else:
563
- summary_lines.append("## Nothing promoted today")
564
-
565
- summary_file.write_text("\n".join(summary_lines))
566
- log(f"Written daily summary: {summary_file}")
567
-
568
- save_history(history)
380
+ # Stage 2: CLI intelligence
381
+ success = consolidate_with_cli(data)
382
+ if not success:
383
+ log("Stage 2 failed — falling back to skip (no v1 fallback)")
569
384
 
570
- # Phase 2: Sensory Register processing
385
+ # Stage 3: Sensory Register (mechanical, kept from v1)
571
386
  try:
572
387
  process_sensory_register()
573
388
  except Exception as e:
574
- log(f"Sensory register processing failed: {e}")
389
+ log(f"Sensory register failed: {e}")
575
390
 
576
- # Phase 3: Analyze --force dissonance events from today
391
+ # Stage 3b: Force analysis (mechanical, kept from v1)
577
392
  try:
578
393
  analyze_force_events()
579
394
  except Exception as e:
580
- log(f"Force event analysis failed: {e}")
395
+ log(f"Force analysis failed: {e}")
581
396
 
582
- # Register successful run for catch-up
397
+ # Register successful run
583
398
  try:
584
- state_file = HOME / "claude" / "operations" / ".catchup-state.json"
399
+ state_file = HOME / ".nexo" / "operations" / ".catchup-state.json"
585
400
  state = json.loads(state_file.read_text()) if state_file.exists() else {}
586
401
  state["postmortem"] = datetime.now().isoformat()
587
402
  state_file.write_text(json.dumps(state, indent=2))
588
403
  except Exception:
589
404
  pass
590
405
 
591
- log("=== Consolidation complete ===")
406
+ mark_done()
407
+ log("=== Consolidation v2 complete ===")
592
408
 
593
409
 
594
410
  if __name__ == "__main__":