nexo-brain 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/bin/create-nexo.js +593 -0
- package/package.json +32 -0
- package/scripts/pre-commit-check.sh +55 -0
- package/src/cognitive.py +1224 -0
- package/src/db.py +2283 -0
- package/src/hooks/caffeinate-guard.sh +8 -0
- package/src/hooks/capture-session.sh +19 -0
- package/src/hooks/session-start.sh +27 -0
- package/src/hooks/session-stop.sh +11 -0
- package/src/plugin_loader.py +136 -0
- package/src/plugins/__init__.py +0 -0
- package/src/plugins/agents.py +52 -0
- package/src/plugins/backup.py +103 -0
- package/src/plugins/cognitive_memory.py +305 -0
- package/src/plugins/entities.py +61 -0
- package/src/plugins/episodic_memory.py +391 -0
- package/src/plugins/evolution.py +113 -0
- package/src/plugins/guard.py +346 -0
- package/src/plugins/preferences.py +47 -0
- package/src/scripts/nexo-auto-update.py +213 -0
- package/src/scripts/nexo-catchup.py +179 -0
- package/src/scripts/nexo-cognitive-decay.py +82 -0
- package/src/scripts/nexo-daily-self-audit.py +532 -0
- package/src/scripts/nexo-postmortem-consolidator.py +594 -0
- package/src/scripts/nexo-sleep.py +762 -0
- package/src/scripts/nexo-synthesis.py +537 -0
- package/src/server.py +560 -0
- package/src/tools_coordination.py +102 -0
- package/src/tools_credentials.py +64 -0
- package/src/tools_learnings.py +180 -0
- package/src/tools_menu.py +208 -0
- package/src/tools_reminders.py +80 -0
- package/src/tools_reminders_crud.py +157 -0
- package/src/tools_sessions.py +169 -0
- package/src/tools_task_history.py +57 -0
- package/templates/CLAUDE.md.template +89 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NEXO Synthesis Engine — Daily intelligence brief.
|
|
4
|
+
|
|
5
|
+
Runs every 2 hours via LaunchAgent. Executes ONCE per day (internal gate).
|
|
6
|
+
|
|
7
|
+
Zero external dependencies beyond stdlib + sqlite3.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import sys
|
|
15
|
+
from collections import Counter, defaultdict
|
|
16
|
+
from datetime import datetime, date, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# ─── Paths ────────────────────────────────────────────────────────────────────
|
|
20
|
+
HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
21
|
+
CLAUDE_DIR = HOME / "claude"
|
|
22
|
+
COORD_DIR = CLAUDE_DIR / "coordination"
|
|
23
|
+
|
|
24
|
+
NEXO_DB = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))) / "nexo.db"
|
|
25
|
+
|
|
26
|
+
OUTPUT_FILE = COORD_DIR / "daily-synthesis.md"
|
|
27
|
+
SYNTHESIS_LOG = COORD_DIR / "synthesis-log.json"
|
|
28
|
+
LAST_RUN_FILE = COORD_DIR / "synthesis-last-run"
|
|
29
|
+
LOCK_FILE = COORD_DIR / "synthesis.lock"
|
|
30
|
+
|
|
31
|
+
TODAY = date.today()
|
|
32
|
+
TODAY_STR = TODAY.isoformat()
|
|
33
|
+
SEVEN_DAYS_AGO = (TODAY - timedelta(days=7)).isoformat()
|
|
34
|
+
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─── Utilities ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def log(msg: str):
|
|
40
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
41
|
+
print(f"[{ts}] {msg}", flush=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def should_run() -> bool:
|
|
45
|
+
"""Gate: run at most once per day."""
|
|
46
|
+
if LAST_RUN_FILE.exists():
|
|
47
|
+
last = LAST_RUN_FILE.read_text().strip()
|
|
48
|
+
if last == TODAY_STR:
|
|
49
|
+
log(f"Already ran today ({TODAY_STR}). Skipping.")
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mark_done():
|
|
55
|
+
LAST_RUN_FILE.write_text(TODAY_STR)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def acquire_lock():
|
|
59
|
+
lock_fd = open(LOCK_FILE, "w")
|
|
60
|
+
try:
|
|
61
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
62
|
+
return lock_fd
|
|
63
|
+
except BlockingIOError:
|
|
64
|
+
log("Another instance is running. Exiting.")
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def release_lock(lock_fd):
|
|
69
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
70
|
+
lock_fd.close()
|
|
71
|
+
try:
|
|
72
|
+
LOCK_FILE.unlink()
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def safe_query(db_path: Path, sql: str, params=()) -> list:
|
|
78
|
+
"""Run a query against a SQLite DB, return rows or [] on any error."""
|
|
79
|
+
if not db_path.exists():
|
|
80
|
+
return []
|
|
81
|
+
try:
|
|
82
|
+
conn = sqlite3.connect(db_path)
|
|
83
|
+
conn.row_factory = sqlite3.Row
|
|
84
|
+
cur = conn.execute(sql, params)
|
|
85
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
86
|
+
conn.close()
|
|
87
|
+
return rows
|
|
88
|
+
except Exception as e:
|
|
89
|
+
log(f"Query error on {db_path.name}: {e}")
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def truncate(text: str, max_len: int = 200) -> str:
|
|
94
|
+
if not text:
|
|
95
|
+
return ""
|
|
96
|
+
text = text.strip().replace("\n", " ")
|
|
97
|
+
return text[:max_len] + ("…" if len(text) > max_len else "")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ─── Section builders ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def section_learnings() -> str:
|
|
103
|
+
rows = safe_query(
|
|
104
|
+
NEXO_DB,
|
|
105
|
+
"SELECT category, title, content, reasoning FROM learnings "
|
|
106
|
+
"WHERE date(created_at, 'unixepoch') = ? ORDER BY created_at DESC",
|
|
107
|
+
(TODAY_STR,),
|
|
108
|
+
)
|
|
109
|
+
if not rows:
|
|
110
|
+
return "Sin errores nuevos registrados."
|
|
111
|
+
|
|
112
|
+
lines = []
|
|
113
|
+
for r in rows:
|
|
114
|
+
cat = r.get("category") or "general"
|
|
115
|
+
title = r.get("title") or ""
|
|
116
|
+
content = truncate(r.get("content") or "", 180)
|
|
117
|
+
lines.append(f"- **[{cat}]** {title}: {content}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def section_decisions() -> str:
|
|
122
|
+
# decisions table uses columns: domain, decision, alternatives, based_on, outcome
|
|
123
|
+
rows = safe_query(
|
|
124
|
+
NEXO_DB,
|
|
125
|
+
"SELECT domain, decision, alternatives, based_on, outcome FROM decisions "
|
|
126
|
+
"WHERE date(created_at) = ? ORDER BY created_at DESC",
|
|
127
|
+
(TODAY_STR,),
|
|
128
|
+
)
|
|
129
|
+
if not rows:
|
|
130
|
+
return "Sin decisiones registradas."
|
|
131
|
+
|
|
132
|
+
lines = []
|
|
133
|
+
for r in rows:
|
|
134
|
+
domain = r.get("domain") or ""
|
|
135
|
+
chosen = truncate(r.get("decision") or "", 160)
|
|
136
|
+
discarded = truncate(r.get("alternatives") or "", 120)
|
|
137
|
+
why = truncate(r.get("based_on") or "", 120)
|
|
138
|
+
outcome = r.get("outcome") or ""
|
|
139
|
+
|
|
140
|
+
line = f"- **[{domain}]** Elegido: {chosen}"
|
|
141
|
+
if discarded:
|
|
142
|
+
line += f"\n Descartado: {discarded}"
|
|
143
|
+
if why:
|
|
144
|
+
line += f"\n Por: {why}"
|
|
145
|
+
if outcome:
|
|
146
|
+
line += f"\n Resultado: {truncate(outcome, 100)}"
|
|
147
|
+
lines.append(line)
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def section_changes() -> str:
|
|
152
|
+
rows = safe_query(
|
|
153
|
+
NEXO_DB,
|
|
154
|
+
"SELECT files, what_changed, why, risks, affects FROM change_log "
|
|
155
|
+
"WHERE date(created_at) = ? ORDER BY created_at DESC",
|
|
156
|
+
(TODAY_STR,),
|
|
157
|
+
)
|
|
158
|
+
if not rows:
|
|
159
|
+
return "Sin cambios de código registrados."
|
|
160
|
+
|
|
161
|
+
# Group by "system" (first part of first file path)
|
|
162
|
+
by_system = defaultdict(list)
|
|
163
|
+
for r in rows:
|
|
164
|
+
files_raw = r.get("files") or ""
|
|
165
|
+
# Take first file, extract top-level system name
|
|
166
|
+
first_file = files_raw.split(",")[0].strip()
|
|
167
|
+
parts = [p for p in first_file.replace("\\", "/").split("/") if p and p != "_public"]
|
|
168
|
+
system = parts[0] if parts else "misc"
|
|
169
|
+
by_system[system].append(r)
|
|
170
|
+
|
|
171
|
+
lines = []
|
|
172
|
+
for system, entries in by_system.items():
|
|
173
|
+
lines.append(f"**{system}** ({len(entries)} cambio{'s' if len(entries) > 1 else ''}):")
|
|
174
|
+
for r in entries[:3]: # cap per system
|
|
175
|
+
what = truncate(r.get("what_changed") or "", 160)
|
|
176
|
+
risks = truncate(r.get("risks") or "", 100)
|
|
177
|
+
lines.append(f" - {what}")
|
|
178
|
+
if risks:
|
|
179
|
+
lines.append(f" ⚠ Riesgos: {risks}")
|
|
180
|
+
return "\n".join(lines)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def section_patterns() -> str:
|
|
184
|
+
# Learnings by category — last 7 days
|
|
185
|
+
learn_rows = safe_query(
|
|
186
|
+
NEXO_DB,
|
|
187
|
+
"SELECT category, title FROM learnings "
|
|
188
|
+
"WHERE date(created_at, 'unixepoch') >= ? ORDER BY created_at DESC",
|
|
189
|
+
(SEVEN_DAYS_AGO,),
|
|
190
|
+
)
|
|
191
|
+
# change_log — last 7 days
|
|
192
|
+
change_rows = safe_query(
|
|
193
|
+
NEXO_DB,
|
|
194
|
+
"SELECT files FROM change_log WHERE date(created_at) >= ?",
|
|
195
|
+
(SEVEN_DAYS_AGO,),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
total_learn = len(learn_rows)
|
|
199
|
+
total_changes = len(change_rows)
|
|
200
|
+
|
|
201
|
+
if total_learn < 3 and total_changes < 3:
|
|
202
|
+
return "Datos insuficientes para análisis de patrones (< 7 días)."
|
|
203
|
+
|
|
204
|
+
lines = []
|
|
205
|
+
|
|
206
|
+
# Categories with most learnings
|
|
207
|
+
if learn_rows:
|
|
208
|
+
cat_counter = Counter(r.get("category") or "general" for r in learn_rows)
|
|
209
|
+
top_cats = cat_counter.most_common(3)
|
|
210
|
+
lines.append(f"**Áreas con más errores** (últimos 7d, {total_learn} learnings):")
|
|
211
|
+
for cat, count in top_cats:
|
|
212
|
+
lines.append(f" - {cat}: {count} {'error' if count == 1 else 'errores'}")
|
|
213
|
+
|
|
214
|
+
# Systems most touched in change_log
|
|
215
|
+
if change_rows:
|
|
216
|
+
sys_counter: Counter = Counter()
|
|
217
|
+
for r in change_rows:
|
|
218
|
+
files_raw = r.get("files") or ""
|
|
219
|
+
for f in files_raw.split(",")[:3]:
|
|
220
|
+
f = f.strip()
|
|
221
|
+
parts = [p for p in f.replace("\\", "/").split("/") if p and p != "_public"]
|
|
222
|
+
if parts:
|
|
223
|
+
sys_counter[parts[0]] += 1
|
|
224
|
+
top_sys = sys_counter.most_common(3)
|
|
225
|
+
lines.append(f"**Sistemas más tocados** (últimos 7d, {total_changes} cambios):")
|
|
226
|
+
for sys_name, count in top_sys:
|
|
227
|
+
lines.append(f" - {sys_name}: {count} {'modificación' if count == 1 else 'modificaciones'}")
|
|
228
|
+
|
|
229
|
+
# Recurring error patterns — categories with learnings on 3+ different days
|
|
230
|
+
if learn_rows:
|
|
231
|
+
# Get daily breakdown per category
|
|
232
|
+
daily_cats = safe_query(
|
|
233
|
+
NEXO_DB,
|
|
234
|
+
"SELECT category, date(created_at, 'unixepoch') as day "
|
|
235
|
+
"FROM learnings WHERE date(created_at, 'unixepoch') >= ? "
|
|
236
|
+
"GROUP BY category, day",
|
|
237
|
+
(SEVEN_DAYS_AGO,),
|
|
238
|
+
)
|
|
239
|
+
if daily_cats:
|
|
240
|
+
cat_days = Counter(r.get("category") or "general" for r in daily_cats)
|
|
241
|
+
recurring = [(c, d) for c, d in cat_days.items() if d >= 3]
|
|
242
|
+
if recurring:
|
|
243
|
+
lines.append("**Categorías con errores recurrentes** (3+ días distintos):")
|
|
244
|
+
for cat, days in sorted(recurring, key=lambda x: -x[1]):
|
|
245
|
+
lines.append(f" - {cat}: errores en {days} días — punto débil")
|
|
246
|
+
|
|
247
|
+
return "\n".join(lines) if lines else "Sin patrones significativos detectados."
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def section_manana() -> str:
|
|
251
|
+
lines = []
|
|
252
|
+
|
|
253
|
+
# Reminders due <= tomorrow, PENDIENTE
|
|
254
|
+
rem_rows = safe_query(
|
|
255
|
+
NEXO_DB,
|
|
256
|
+
"SELECT id, date, description, category FROM reminders "
|
|
257
|
+
"WHERE status LIKE 'PENDIENTE%' AND date IS NOT NULL AND date <= ? "
|
|
258
|
+
"ORDER BY date ASC",
|
|
259
|
+
(TOMORROW,),
|
|
260
|
+
)
|
|
261
|
+
if rem_rows:
|
|
262
|
+
lines.append("### Recordatorios vencidos/mañana")
|
|
263
|
+
for r in rem_rows:
|
|
264
|
+
d = r.get("date") or ""
|
|
265
|
+
cat = r.get("category") or ""
|
|
266
|
+
desc = truncate(r.get("description") or "", 150)
|
|
267
|
+
overdue = " ⚠ VENCIDO" if d and d < TODAY_STR else ""
|
|
268
|
+
lines.append(f"- [{d}]{overdue} {desc}" + (f" ({cat})" if cat else ""))
|
|
269
|
+
else:
|
|
270
|
+
lines.append("### Recordatorios\nNinguno vencido ni para mañana.")
|
|
271
|
+
|
|
272
|
+
# Followups due <= tomorrow, PENDIENTE
|
|
273
|
+
fol_rows = safe_query(
|
|
274
|
+
NEXO_DB,
|
|
275
|
+
"SELECT id, date, description FROM followups "
|
|
276
|
+
"WHERE status = 'PENDIENTE' AND date IS NOT NULL AND date <= ? "
|
|
277
|
+
"ORDER BY date ASC",
|
|
278
|
+
(TOMORROW,),
|
|
279
|
+
)
|
|
280
|
+
if fol_rows:
|
|
281
|
+
lines.append("### Followups vencidos/mañana")
|
|
282
|
+
for r in fol_rows:
|
|
283
|
+
d = r.get("date") or ""
|
|
284
|
+
desc = truncate(r.get("description") or "", 150)
|
|
285
|
+
overdue = " ⚠ VENCIDO" if d and d < TODAY_STR else ""
|
|
286
|
+
lines.append(f"- [{d}]{overdue} {desc}")
|
|
287
|
+
else:
|
|
288
|
+
lines.append("### Followups\nNinguno vencido ni para mañana.")
|
|
289
|
+
|
|
290
|
+
# Last 3 session diary entries — pending + next_session_context
|
|
291
|
+
diary_rows = safe_query(
|
|
292
|
+
NEXO_DB,
|
|
293
|
+
"SELECT domain, pending, context_next, created_at FROM session_diary "
|
|
294
|
+
"ORDER BY created_at DESC LIMIT 3",
|
|
295
|
+
)
|
|
296
|
+
if diary_rows:
|
|
297
|
+
lines.append("### Contexto activo (últimas sesiones)")
|
|
298
|
+
for r in diary_rows:
|
|
299
|
+
domain = r.get("domain") or "general"
|
|
300
|
+
pending = truncate(r.get("pending") or "", 200)
|
|
301
|
+
nxt = truncate(r.get("context_next") or "", 200)
|
|
302
|
+
ts = r.get("created_at") or ""
|
|
303
|
+
if pending or nxt:
|
|
304
|
+
lines.append(f"**[{domain}]** ({ts[:16]}):")
|
|
305
|
+
if pending:
|
|
306
|
+
lines.append(f" Pendiente: {pending}")
|
|
307
|
+
if nxt:
|
|
308
|
+
lines.append(f" Para la próxima: {nxt}")
|
|
309
|
+
|
|
310
|
+
return "\n".join(lines) if lines else "Sin elementos para mañana."
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def section_autoevaluacion() -> str:
|
|
314
|
+
diary_rows = safe_query(
|
|
315
|
+
NEXO_DB,
|
|
316
|
+
"SELECT mental_state, francisco_signals, self_critique, summary, created_at FROM session_diary "
|
|
317
|
+
"WHERE date(created_at) = ? ORDER BY created_at DESC",
|
|
318
|
+
(TODAY_STR,),
|
|
319
|
+
)
|
|
320
|
+
if not diary_rows:
|
|
321
|
+
return "Sin diarios de sesión registrados hoy."
|
|
322
|
+
|
|
323
|
+
lines = []
|
|
324
|
+
|
|
325
|
+
# Self-critique section (NEW — most important)
|
|
326
|
+
all_critiques = []
|
|
327
|
+
for r in diary_rows:
|
|
328
|
+
sc = r.get("self_critique") or ""
|
|
329
|
+
if sc.strip() and not sc.strip().lower().startswith("sin autocrítica"):
|
|
330
|
+
all_critiques.append(truncate(sc, 300))
|
|
331
|
+
|
|
332
|
+
if all_critiques:
|
|
333
|
+
lines.append(f"**AUTOCRÍTICAS ({len(all_critiques)} sesiones con fallos detectados):**")
|
|
334
|
+
for c in all_critiques[:5]:
|
|
335
|
+
lines.append(f" - {c}")
|
|
336
|
+
lines.append("**ACCIÓN:** Estas autocríticas deben informar el comportamiento de mañana. Si un patrón se repite 3+ días, el consolidador nocturno lo promoverá a memoria permanente.")
|
|
337
|
+
lines.append("")
|
|
338
|
+
|
|
339
|
+
# francisco_signals patterns
|
|
340
|
+
all_signals = []
|
|
341
|
+
mental_states = []
|
|
342
|
+
for r in diary_rows:
|
|
343
|
+
sig = r.get("francisco_signals") or ""
|
|
344
|
+
if sig.strip():
|
|
345
|
+
all_signals.append(truncate(sig, 200))
|
|
346
|
+
ms = r.get("mental_state") or ""
|
|
347
|
+
if ms.strip():
|
|
348
|
+
mental_states.append(truncate(ms, 200))
|
|
349
|
+
|
|
350
|
+
if francisco_signals_text := "\n".join(f" - {s}" for s in all_signals[:3] if s):
|
|
351
|
+
lines.append(f"**Señales de the user:**\n{francisco_signals_text}")
|
|
352
|
+
|
|
353
|
+
if mental_states:
|
|
354
|
+
lines.append(f"**Estado mental de sesiones:**")
|
|
355
|
+
for ms in mental_states[:2]:
|
|
356
|
+
lines.append(f" - {ms}")
|
|
357
|
+
|
|
358
|
+
# Derive what to do differently based on signal analysis
|
|
359
|
+
if all_signals:
|
|
360
|
+
# Detect repeated corrections
|
|
361
|
+
correction_words = ["corrig", "frustrad", "no lo entiend", "exig", "repet",
|
|
362
|
+
"no debería", "por qué no", "otra vez", "cansando",
|
|
363
|
+
"siempre espera", "reactivo", "no te adelant"]
|
|
364
|
+
correction_count = sum(
|
|
365
|
+
1 for s in all_signals
|
|
366
|
+
if any(w in s.lower() for w in correction_words)
|
|
367
|
+
)
|
|
368
|
+
if correction_count >= 2:
|
|
369
|
+
lines.append(f"**ALERTA:** the user corrigió {correction_count} veces hoy — revisar qué se está repitiendo.")
|
|
370
|
+
lines.append("**Para mañana:** Revisar señales anteriores antes de actuar.")
|
|
371
|
+
elif not diary_rows:
|
|
372
|
+
lines.append("**Para mañana:** Recordar escribir diario al cerrar sesión.")
|
|
373
|
+
|
|
374
|
+
# Check for postmortem daily summary
|
|
375
|
+
postmortem_file = COORD_DIR / "postmortem-daily.md"
|
|
376
|
+
if postmortem_file.exists():
|
|
377
|
+
pm_content = postmortem_file.read_text().strip()
|
|
378
|
+
if "Promovido a memoria permanente" in pm_content:
|
|
379
|
+
lines.append("")
|
|
380
|
+
lines.append("**REGLAS NUEVAS PERMANENTES (generadas anoche por el consolidador):**")
|
|
381
|
+
for line in pm_content.split("\n"):
|
|
382
|
+
if line.startswith("- ") and "Promovido" not in line:
|
|
383
|
+
lines.append(f" {line}")
|
|
384
|
+
|
|
385
|
+
return "\n".join(lines) if lines else "Sin datos de auto-evaluación."
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def section_francisco_observer() -> str:
|
|
389
|
+
"""Track the user's patterns: forgotten ideas, abandoned topics, recurring requests."""
|
|
390
|
+
lines = []
|
|
391
|
+
|
|
392
|
+
# 1. Reminders without dates (ideas that accumulate without agenda)
|
|
393
|
+
no_date = safe_query(
|
|
394
|
+
NEXO_DB,
|
|
395
|
+
"SELECT id, description FROM reminders "
|
|
396
|
+
"WHERE date IS NULL AND status LIKE 'PENDIENTE%' ORDER BY rowid",
|
|
397
|
+
)
|
|
398
|
+
if no_date:
|
|
399
|
+
lines.append(f"**Ideas sin agenda:** {len(no_date)} reminders sin fecha")
|
|
400
|
+
# Show oldest 3 as examples
|
|
401
|
+
for r in no_date[:3]:
|
|
402
|
+
desc = truncate(r.get("description") or "", 80)
|
|
403
|
+
lines.append(f" - {r.get('id')}: {desc}")
|
|
404
|
+
if len(no_date) > 3:
|
|
405
|
+
lines.append(f" - ... y {len(no_date) - 3} más")
|
|
406
|
+
|
|
407
|
+
# 2. Followups waiting on the user or external responses
|
|
408
|
+
waiting = safe_query(
|
|
409
|
+
NEXO_DB,
|
|
410
|
+
"SELECT id, description, date FROM followups "
|
|
411
|
+
"WHERE status = 'PENDIENTE' "
|
|
412
|
+
"AND (description LIKE '%María%' OR description LIKE '%respuesta%' "
|
|
413
|
+
" OR description LIKE '%preguntar%' OR description LIKE '%confirme%' "
|
|
414
|
+
" OR description LIKE '%decidió%') "
|
|
415
|
+
"ORDER BY date",
|
|
416
|
+
)
|
|
417
|
+
if waiting:
|
|
418
|
+
lines.append(f"**Esperando respuesta/decisión de the user o terceros:** {len(waiting)}")
|
|
419
|
+
for r in waiting[:5]:
|
|
420
|
+
d = r.get("date") or "sin fecha"
|
|
421
|
+
desc = truncate(r.get("description") or "", 100)
|
|
422
|
+
lines.append(f" - {r.get('id')} ({d}): {desc}")
|
|
423
|
+
|
|
424
|
+
# 3. Overdue reminders that keep getting postponed (same reminder, multiple updates)
|
|
425
|
+
# Detect by looking at reminders with dates far past
|
|
426
|
+
stale = safe_query(
|
|
427
|
+
NEXO_DB,
|
|
428
|
+
"SELECT id, description, date FROM reminders "
|
|
429
|
+
"WHERE status LIKE 'PENDIENTE%' AND date IS NOT NULL AND date < ? "
|
|
430
|
+
"ORDER BY date ASC LIMIT 5",
|
|
431
|
+
(TODAY_STR,),
|
|
432
|
+
)
|
|
433
|
+
if stale:
|
|
434
|
+
lines.append(f"**Recordatorios vencidos no atendidos:**")
|
|
435
|
+
for r in stale:
|
|
436
|
+
desc = truncate(r.get("description") or "", 80)
|
|
437
|
+
lines.append(f" - {r.get('id')} (venció {r.get('date')}): {desc}")
|
|
438
|
+
|
|
439
|
+
if not lines:
|
|
440
|
+
return "Sin observaciones sobre patrones de the user."
|
|
441
|
+
|
|
442
|
+
return "\n".join(lines)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ─── Log history ──────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
def append_synthesis_log(entry: dict):
|
|
448
|
+
log_data = []
|
|
449
|
+
if SYNTHESIS_LOG.exists():
|
|
450
|
+
try:
|
|
451
|
+
log_data = json.loads(SYNTHESIS_LOG.read_text())
|
|
452
|
+
except Exception:
|
|
453
|
+
log_data = []
|
|
454
|
+
log_data.append(entry)
|
|
455
|
+
# Keep last 30 entries
|
|
456
|
+
log_data = log_data[-30:]
|
|
457
|
+
SYNTHESIS_LOG.write_text(json.dumps(log_data, ensure_ascii=False, indent=2))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
def main():
|
|
463
|
+
log("NEXO Synthesis Engine starting.")
|
|
464
|
+
|
|
465
|
+
if not should_run():
|
|
466
|
+
sys.exit(0)
|
|
467
|
+
|
|
468
|
+
lock_fd = acquire_lock()
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
COORD_DIR.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
|
|
473
|
+
now = datetime.now()
|
|
474
|
+
ts = now.strftime("%Y-%m-%d %H:%M")
|
|
475
|
+
log("Querying databases...")
|
|
476
|
+
|
|
477
|
+
s_learnings = section_learnings()
|
|
478
|
+
s_decisions = section_decisions()
|
|
479
|
+
s_changes = section_changes()
|
|
480
|
+
s_patterns = section_patterns()
|
|
481
|
+
s_manana = section_manana()
|
|
482
|
+
s_autoeval = section_autoevaluacion()
|
|
483
|
+
s_francisco = section_francisco_observer()
|
|
484
|
+
|
|
485
|
+
md = f"""# NEXO Daily Synthesis — {TODAY_STR}
|
|
486
|
+
Generated at {ts}
|
|
487
|
+
|
|
488
|
+
## Errores y Lecciones (hoy)
|
|
489
|
+
{s_learnings}
|
|
490
|
+
|
|
491
|
+
## Decisiones Tomadas
|
|
492
|
+
{s_decisions}
|
|
493
|
+
|
|
494
|
+
## Sistemas Tocados
|
|
495
|
+
{s_changes}
|
|
496
|
+
|
|
497
|
+
## Patrones Detectados
|
|
498
|
+
{s_patterns}
|
|
499
|
+
|
|
500
|
+
## the user — Observaciones
|
|
501
|
+
{s_francisco}
|
|
502
|
+
|
|
503
|
+
## Mañana
|
|
504
|
+
{s_manana}
|
|
505
|
+
|
|
506
|
+
## Auto-Evaluación
|
|
507
|
+
{s_autoeval}
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
OUTPUT_FILE.write_text(md, encoding="utf-8")
|
|
511
|
+
log(f"Written: {OUTPUT_FILE}")
|
|
512
|
+
|
|
513
|
+
line_count = len(md.splitlines())
|
|
514
|
+
log(f"Output: {line_count} lines.")
|
|
515
|
+
|
|
516
|
+
# Log history
|
|
517
|
+
append_synthesis_log({
|
|
518
|
+
"date": TODAY_STR,
|
|
519
|
+
"generated_at": ts,
|
|
520
|
+
"lines": line_count,
|
|
521
|
+
"learnings_today": s_learnings.count("\n- ") + (1 if s_learnings.startswith("- ") else 0),
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
mark_done()
|
|
525
|
+
log("Done.")
|
|
526
|
+
|
|
527
|
+
except Exception as e:
|
|
528
|
+
log(f"Fatal error: {e}")
|
|
529
|
+
import traceback
|
|
530
|
+
traceback.print_exc()
|
|
531
|
+
sys.exit(1)
|
|
532
|
+
finally:
|
|
533
|
+
release_lock(lock_fd)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__":
|
|
537
|
+
main()
|