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.
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
- package/src/cognitive.py +45 -0
- package/src/evolution_cycle.py +266 -0
- package/src/plugins/guard.py +235 -1
- package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
- package/src/scripts/check-context.py +257 -0
- package/src/scripts/nexo-catchup.py +59 -5
- package/src/scripts/nexo-cognitive-decay.py +8 -0
- package/src/scripts/nexo-daily-self-audit.py +168 -183
- package/src/scripts/nexo-evolution-run.py +584 -0
- package/src/scripts/nexo-immune.py +108 -91
- package/src/scripts/nexo-learning-validator.py +226 -0
- package/src/scripts/nexo-postmortem-consolidator.py +230 -414
- package/src/scripts/nexo-sleep.py +283 -503
- package/src/scripts/nexo-synthesis.py +141 -432
- package/src/tools_sessions.py +20 -12
|
@@ -1,55 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
NEXO Post-Mortem Consolidator —
|
|
3
|
+
NEXO Post-Mortem Consolidator v2 — The brain consolidates memories.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
30
|
-
|
|
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 =
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
104
|
-
"""
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
return None
|
|
124
|
+
FECHA: {data['date']}
|
|
125
|
+
SESIONES HOY: {len(data['diaries'])} total, {len(diaries_with_critique)} con autocrítica
|
|
162
126
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
{
|
|
130
|
+
FEEDBACKS POSTMORTEM QUE YA EXISTEN ({len(data['existing_feedbacks'])}):
|
|
131
|
+
{json.dumps(data['existing_feedbacks'][:30], ensure_ascii=False)}
|
|
170
132
|
|
|
171
|
-
|
|
133
|
+
REGLAS PERMANENTES RECIENTES:
|
|
134
|
+
{json.dumps(data['history_summary'].get('recent_rules', []), ensure_ascii=False)}
|
|
172
135
|
|
|
173
|
-
|
|
136
|
+
INSTRUCCIONES:
|
|
174
137
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
240
|
+
log(" No events from today")
|
|
218
241
|
return
|
|
219
242
|
|
|
220
|
-
log(f" Found {len(today_events)} events
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
418
|
-
log(f" PARADIGM SHIFT
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
466
|
-
|
|
366
|
+
def main():
|
|
367
|
+
if already_ran_today():
|
|
368
|
+
log("Already ran today. Skipping.")
|
|
467
369
|
return
|
|
468
370
|
|
|
469
|
-
|
|
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
|
-
#
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
#
|
|
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
|
|
389
|
+
log(f"Sensory register failed: {e}")
|
|
575
390
|
|
|
576
|
-
#
|
|
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
|
|
395
|
+
log(f"Force analysis failed: {e}")
|
|
581
396
|
|
|
582
|
-
# Register successful run
|
|
397
|
+
# Register successful run
|
|
583
398
|
try:
|
|
584
|
-
state_file = HOME / "
|
|
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
|
-
|
|
406
|
+
mark_done()
|
|
407
|
+
log("=== Consolidation v2 complete ===")
|
|
592
408
|
|
|
593
409
|
|
|
594
410
|
if __name__ == "__main__":
|