nexo-brain 2.6.20 → 2.6.21

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.20",
3
+ "version": "2.6.21",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -38,7 +38,7 @@ That means NEXO now manages not only the shared runtime and MCP wiring, but also
38
38
  - For Codex specifically, `nexo chat` and Codex headless automation inject the current bootstrap explicitly, so Codex starts as NEXO even when plain global Codex startup is inconsistent about global instructions.
39
39
  - Deep Sleep now reads both Claude Code and Codex transcript stores, so overnight analysis still works even when the user spends the day in Codex.
40
40
 
41
- Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, `2.6.16` pushes the system further in three directions, `2.6.17` finishes the annoying last-mile migration bugs for real existing installs, `2.6.18` tightens the remaining practical gaps around manual Codex use, Deep Sleep horizon artifacts, and retrieval honesty, and `2.6.20` makes the recommended Claude profile explicit across installer, runtime defaults, existing installs, and the update path itself: `Opus 4.6 with 1M context`.
41
+ Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, `2.6.16` pushes the system further in three directions, `2.6.17` finishes the annoying last-mile migration bugs for real existing installs, `2.6.18` tightens the remaining practical gaps around manual Codex use, Deep Sleep horizon artifacts, and retrieval honesty, `2.6.20` makes the recommended Claude profile explicit across installer, runtime defaults, existing installs, and the update path itself with `Opus 4.6 with 1M context`, and `2.6.21` upgrades Deep Sleep from passive nightly analysis toward concrete engineering action.
42
42
 
43
43
  - Codex now gets managed global bootstrap/model sync in `~/.codex/config.toml`, so sessions opened outside `nexo chat` are much less likely to start as plain Codex.
44
44
  - Codex config now also persists a managed `mcp_servers.nexo` entry, so the shared brain survives even if ad-hoc Codex MCP state drifts.
@@ -47,6 +47,7 @@ Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the inst
47
47
  - Retrieval explanations now surface confidence and the auto-strategy that fired, while associative expansion trims itself back to `top_k` instead of leaking low-signal neighbors.
48
48
  - Deep Sleep now blends recent context with older context over a 60-day horizon, and memory decay now tracks per-memory `stability` and `difficulty` instead of relying only on global decay constants.
49
49
  - Deep Sleep now also carries project-priority weighting into its long-horizon context and writes reusable weekly/monthly summary artifacts instead of reasoning only day by day.
50
+ - Deep Sleep now semantically deduplicates followups, consolidates overlapping learnings, flags contradictory learnings for review, and backfills explicit engineering followups when recurring patterns imply a concrete fix.
50
51
  - Existing installs that already had NEXO connected to Codex now backfill that client state automatically during update/sync, so the managed Codex bootstrap actually lands without manual cleanup.
51
52
  - Bootstrap docs now fall back to the operator name `NEXO` when local metadata is blank, avoiding broken headings in `CLAUDE.md` and `AGENTS.md`.
52
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.20",
3
+ "version": "2.6.21",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -17,10 +17,12 @@ Environment variables:
17
17
  import hashlib
18
18
  import json
19
19
  import os
20
+ import re
20
21
  import sqlite3
21
22
  import sys
22
23
  from collections import Counter
23
24
  from datetime import datetime, timedelta
25
+ from difflib import SequenceMatcher
24
26
  from pathlib import Path
25
27
 
26
28
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
@@ -34,6 +36,36 @@ COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
34
36
  OPERATIONS_DIR = NEXO_HOME / "operations"
35
37
  BACKUP_DIR = DEEP_SLEEP_DIR # backups stored alongside outputs
36
38
 
39
+ STOPWORDS = {
40
+ "the", "a", "an", "and", "or", "but", "with", "for", "from", "into", "onto",
41
+ "that", "this", "these", "those", "have", "has", "had", "will", "would",
42
+ "could", "should", "must", "need", "needs", "your", "their", "there", "here",
43
+ "about", "before", "after", "during", "through", "without", "within", "while",
44
+ "que", "con", "para", "por", "los", "las", "una", "uno", "sobre", "desde",
45
+ "cuando", "como", "pero", "todo", "toda", "cada", "into", "across", "using",
46
+ }
47
+ CONCRETE_ACTION_VERBS = {
48
+ "add", "implement", "create", "write", "build", "introduce", "enforce",
49
+ "automate", "validate", "check", "verify", "guard", "fix", "migrate",
50
+ "review", "reconcile", "pin", "sync", "instrument",
51
+ }
52
+ NEGATION_PATTERNS = (
53
+ "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
54
+ "disable", "disabled", "remove", "ban", "bypass",
55
+ )
56
+ CONTRADICTION_PAIRS = (
57
+ ("enable", "disable"),
58
+ ("use", "avoid"),
59
+ ("add", "remove"),
60
+ ("allow", "forbid"),
61
+ ("always", "never"),
62
+ ("before", "after"),
63
+ ("require", "skip"),
64
+ ("validate", "bypass"),
65
+ ("include", "exclude"),
66
+ )
67
+ TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
68
+
37
69
 
38
70
  def generate_run_id(target_date: str) -> str:
39
71
  """Generate a unique run ID for this execution."""
@@ -75,41 +107,435 @@ def backup_db(db_path: Path, run_id: str) -> Path | None:
75
107
  return None
76
108
 
77
109
 
110
+ def _table_columns(db_path: Path, table: str) -> set[str]:
111
+ cache_key = (str(db_path), table)
112
+ if cache_key in TABLE_COLUMNS_CACHE:
113
+ return TABLE_COLUMNS_CACHE[cache_key]
114
+ if not db_path.exists():
115
+ TABLE_COLUMNS_CACHE[cache_key] = set()
116
+ return set()
117
+ try:
118
+ conn = sqlite3.connect(str(db_path))
119
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
120
+ conn.close()
121
+ except Exception:
122
+ TABLE_COLUMNS_CACHE[cache_key] = set()
123
+ return set()
124
+ cols = {str(row[1]) for row in rows}
125
+ TABLE_COLUMNS_CACHE[cache_key] = cols
126
+ return cols
127
+
128
+
129
+ def _row_dict(row) -> dict:
130
+ if row is None:
131
+ return {}
132
+ if isinstance(row, sqlite3.Row):
133
+ return dict(row)
134
+ return dict(zip(row.keys(), row)) if hasattr(row, "keys") else dict(row)
135
+
136
+
137
+ def _normalize_text(value: str) -> str:
138
+ text = str(value or "").lower()
139
+ text = re.sub(r"https?://\S+", " ", text)
140
+ text = re.sub(r"[^a-z0-9_/\-\s]+", " ", text)
141
+ text = re.sub(r"\s+", " ", text)
142
+ return text.strip()
143
+
144
+
145
+ def _tokenize(value: str) -> list[str]:
146
+ tokens = re.findall(r"[a-z0-9_/-]+", _normalize_text(value))
147
+ return [token for token in tokens if len(token) > 2 and token not in STOPWORDS]
148
+
149
+
150
+ def _text_similarity(left: str, right: str) -> float:
151
+ normalized_left = _normalize_text(left)
152
+ normalized_right = _normalize_text(right)
153
+ if not normalized_left or not normalized_right:
154
+ return 0.0
155
+ if normalized_left == normalized_right:
156
+ return 1.0
157
+
158
+ left_tokens = set(_tokenize(normalized_left))
159
+ right_tokens = set(_tokenize(normalized_right))
160
+ shared = left_tokens & right_tokens
161
+ if not shared:
162
+ return SequenceMatcher(None, normalized_left, normalized_right).ratio()
163
+
164
+ seq = SequenceMatcher(None, normalized_left, normalized_right).ratio()
165
+ jaccard = len(shared) / len(left_tokens | right_tokens) if (left_tokens or right_tokens) else 0.0
166
+ overlap = len(shared) / min(len(left_tokens), len(right_tokens)) if min(len(left_tokens), len(right_tokens)) else 0.0
167
+ containment = (
168
+ 1.0
169
+ if normalized_left in normalized_right or normalized_right in normalized_left
170
+ else 0.0
171
+ )
172
+ return round(max((seq * 0.45) + (jaccard * 0.2) + (overlap * 0.35), overlap, (containment * 0.8) + (seq * 0.2)), 4)
173
+
174
+
175
+ def _is_concrete_action(text: str) -> bool:
176
+ tokens = set(_tokenize(text))
177
+ return bool(tokens & CONCRETE_ACTION_VERBS)
178
+
179
+
180
+ def _prefer_due_date(current_value, new_value) -> str:
181
+ current = _parse_any_datetime(current_value)
182
+ new = _parse_any_datetime(new_value)
183
+ if new and (not current or new <= current):
184
+ return str(new_value or "")
185
+ return str(current_value or "")
186
+
187
+
188
+ def _append_note(base: str, note: str) -> str:
189
+ base = str(base or "").strip()
190
+ note = str(note or "").strip()
191
+ if not note:
192
+ return base
193
+ if not base:
194
+ return note
195
+ if note.lower() in base.lower():
196
+ return base
197
+ return f"{base}\n\n{note}"
198
+
199
+
200
+ def _contains_negation(text: str) -> bool:
201
+ lowered = _normalize_text(text)
202
+ return any(token in lowered for token in NEGATION_PATTERNS)
203
+
204
+
205
+ def _negated_action_verbs(text: str) -> set[str]:
206
+ lowered = _normalize_text(text)
207
+ matches = set()
208
+ for pattern in (r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)", r"(?:do not|don't)\s+([a-z0-9_-]+)"):
209
+ matches.update(re.findall(pattern, lowered))
210
+ return {match for match in matches if len(match) > 2}
211
+
212
+
213
+ def _looks_contradictory(existing_text: str, new_text: str) -> bool:
214
+ existing_norm = _normalize_text(existing_text)
215
+ new_norm = _normalize_text(new_text)
216
+ if not existing_norm or not new_norm:
217
+ return False
218
+ existing_tokens = set(_tokenize(existing_norm))
219
+ new_tokens = set(_tokenize(new_norm))
220
+ if len(existing_tokens & new_tokens) < 3:
221
+ return False
222
+ existing_negated_verbs = _negated_action_verbs(existing_norm)
223
+ new_negated_verbs = _negated_action_verbs(new_norm)
224
+ if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
225
+ return True
226
+ if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
227
+ return True
228
+ if _contains_negation(existing_norm) != _contains_negation(new_norm):
229
+ return True
230
+ for positive, negative in CONTRADICTION_PAIRS:
231
+ existing_has_pair = positive in existing_norm or negative in existing_norm
232
+ new_has_pair = positive in new_norm or negative in new_norm
233
+ if existing_has_pair and new_has_pair:
234
+ if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
235
+ return True
236
+ return False
237
+
238
+
239
+ def _fetch_open_followups() -> list[dict]:
240
+ if not NEXO_DB.exists():
241
+ return []
242
+ conn = sqlite3.connect(str(NEXO_DB))
243
+ conn.row_factory = sqlite3.Row
244
+ cols = _table_columns(NEXO_DB, "followups")
245
+ reasoning_sql = ", reasoning" if "reasoning" in cols else ""
246
+ verification_sql = ", verification" if "verification" in cols else ""
247
+ try:
248
+ rows = conn.execute(
249
+ "SELECT id, description, date, status"
250
+ f"{verification_sql}{reasoning_sql} "
251
+ "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
252
+ "AND status NOT IN ('DELETED','archived','blocked','waiting','CANCELLED')"
253
+ ).fetchall()
254
+ finally:
255
+ conn.close()
256
+ return [dict(row) for row in rows]
257
+
258
+
259
+ def _find_similar_followup(description: str, threshold: float = 0.58) -> dict | None:
260
+ candidates = []
261
+ query = str(description or "").strip()
262
+ if not query:
263
+ return None
264
+ query_tokens = set(_tokenize(query))
265
+ for row in _fetch_open_followups():
266
+ haystack = " ".join(
267
+ [
268
+ str(row.get("description", "") or ""),
269
+ str(row.get("verification", "") or ""),
270
+ str(row.get("reasoning", "") or ""),
271
+ ]
272
+ )
273
+ haystack_tokens = set(_tokenize(haystack))
274
+ if len(query_tokens & haystack_tokens) < 2 and _normalize_text(query) not in _normalize_text(haystack):
275
+ continue
276
+ score = _text_similarity(query, haystack)
277
+ if score >= threshold:
278
+ candidates.append({**row, "_similarity": score})
279
+ if not candidates:
280
+ return None
281
+ candidates.sort(key=lambda item: item["_similarity"], reverse=True)
282
+ return candidates[0]
283
+
284
+
285
+ def _touch_existing_followup(existing: dict, *, description: str, date: str = "", reasoning_note: str = "") -> dict:
286
+ cols = _table_columns(NEXO_DB, "followups")
287
+ if not cols:
288
+ return {"success": False, "error": "followups table not found"}
289
+
290
+ updates: dict[str, object] = {}
291
+ existing_description = str(existing.get("description", "") or "")
292
+ if _is_concrete_action(description) and not _is_concrete_action(existing_description):
293
+ updates["description"] = description
294
+ preferred_date = _prefer_due_date(existing.get("date", ""), date)
295
+ if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
296
+ updates["date"] = preferred_date
297
+ if "reasoning" in cols and reasoning_note:
298
+ updates["reasoning"] = _append_note(existing.get("reasoning", ""), reasoning_note)
299
+ if "updated_at" in cols:
300
+ updates["updated_at"] = datetime.now().timestamp()
301
+
302
+ if updates:
303
+ conn = sqlite3.connect(str(NEXO_DB))
304
+ set_clause = ", ".join(f"{column} = ?" for column in updates)
305
+ params = list(updates.values()) + [existing["id"]]
306
+ conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", params)
307
+ conn.commit()
308
+ conn.close()
309
+
310
+ return {
311
+ "success": True,
312
+ "id": existing["id"],
313
+ "outcome": "matched_existing_followup",
314
+ "similarity": existing.get("_similarity", 1.0),
315
+ "updated_existing": bool(updates),
316
+ }
317
+
318
+
319
+ def _fetch_learning_candidates(category: str = "") -> list[dict]:
320
+ if not NEXO_DB.exists():
321
+ return []
322
+ cols = _table_columns(NEXO_DB, "learnings")
323
+ if not cols:
324
+ return []
325
+ select_fields = ["id", "category", "title", "content", "created_at", "updated_at"]
326
+ for optional in ("reasoning", "prevention", "applies_to", "status", "review_due_at", "last_reviewed_at", "weight", "priority"):
327
+ if optional in cols:
328
+ select_fields.append(optional)
329
+ query = f"SELECT {', '.join(select_fields)} FROM learnings"
330
+ params: list[object] = []
331
+ if category and "category" in cols:
332
+ query += " WHERE category = ?"
333
+ params.append(category)
334
+ query += " ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 240"
335
+ conn = sqlite3.connect(str(NEXO_DB))
336
+ conn.row_factory = sqlite3.Row
337
+ try:
338
+ rows = conn.execute(query, tuple(params)).fetchall()
339
+ finally:
340
+ conn.close()
341
+ return [dict(row) for row in rows]
342
+
343
+
344
+ def _find_learning_match(category: str, title: str, content: str) -> dict | None:
345
+ candidates = []
346
+ new_text = " ".join([str(title or ""), str(content or "")]).strip()
347
+ for row in _fetch_learning_candidates(category):
348
+ existing_text = " ".join([str(row.get("title", "") or ""), str(row.get("content", "") or "")])
349
+ similarity = _text_similarity(new_text, existing_text)
350
+ if similarity < 0.58:
351
+ continue
352
+ contradiction = _looks_contradictory(existing_text, new_text)
353
+ candidates.append({**row, "_similarity": similarity, "_contradiction": contradiction})
354
+ if not candidates:
355
+ return None
356
+ candidates.sort(
357
+ key=lambda item: (item["_contradiction"], item["_similarity"], item.get("updated_at", 0) or item.get("created_at", 0)),
358
+ reverse=True,
359
+ )
360
+ return candidates[0]
361
+
362
+
363
+ def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
364
+ if not updates:
365
+ return
366
+ conn = sqlite3.connect(str(NEXO_DB))
367
+ set_clause = ", ".join(f"{column} = ?" for column in updates)
368
+ conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", list(updates.values()) + [learning_id])
369
+ conn.commit()
370
+ conn.close()
371
+
372
+
373
+ def _bump_weight(existing_value, amount: float) -> float:
374
+ try:
375
+ base = float(existing_value or 0)
376
+ except Exception:
377
+ base = 0.0
378
+ return round(min(10.0, base + amount), 2)
379
+
380
+
381
+ def _flag_learning_contradiction(existing: dict, category: str, title: str, content: str) -> dict:
382
+ review_description = (
383
+ f"Reconcile contradictory learning in {category or 'general'}: "
384
+ f"review existing learning #{existing.get('id')} ('{existing.get('title', '')}') "
385
+ f"against new Deep Sleep finding '{title}'. Produce one canonical rule, update guardrails, and remove ambiguity."
386
+ )
387
+ followup_result = create_followup(
388
+ description=review_description,
389
+ date="",
390
+ reasoning_note=f"Contradiction detected against learning #{existing.get('id')}: {content[:240]}",
391
+ )
392
+ return {
393
+ "success": followup_result.get("success", False),
394
+ "id": existing.get("id"),
395
+ "outcome": "contradiction_review",
396
+ "similarity": existing.get("_similarity", 0.0),
397
+ "review_followup_id": followup_result.get("id"),
398
+ "followup_result": followup_result,
399
+ }
400
+
401
+
78
402
  def add_learning(category: str, title: str, content: str) -> dict:
79
403
  """Add a learning to nexo.db. Returns result dict."""
80
404
  if not NEXO_DB.exists():
81
405
  return {"success": False, "error": "nexo.db not found"}
82
406
  try:
407
+ existing = _find_learning_match(category, title, content)
408
+ if existing:
409
+ similarity = existing.get("_similarity", 0.0)
410
+ if existing.get("_contradiction"):
411
+ return _flag_learning_contradiction(existing, category, title, content)
412
+
413
+ updates: dict[str, object] = {}
414
+ columns = _table_columns(NEXO_DB, "learnings")
415
+ if "updated_at" in columns:
416
+ updates["updated_at"] = datetime.now().timestamp()
417
+
418
+ existing_title = _normalize_text(existing.get("title", ""))
419
+ existing_content = _normalize_text(existing.get("content", ""))
420
+ incoming_title = _normalize_text(title)
421
+ incoming_content = _normalize_text(content)
422
+
423
+ if similarity >= 0.95 and (
424
+ existing_title == incoming_title
425
+ or existing_content == incoming_content
426
+ or incoming_content in existing_content
427
+ or existing_content in incoming_content
428
+ ):
429
+ if "weight" in columns:
430
+ updates["weight"] = _bump_weight(existing.get("weight"), 0.1)
431
+ if "last_reviewed_at" in columns:
432
+ updates["last_reviewed_at"] = datetime.now().timestamp()
433
+ if "reasoning" in columns:
434
+ updates["reasoning"] = _append_note(
435
+ existing.get("reasoning", ""),
436
+ f"Reconfirmed by Deep Sleep on {datetime.now().strftime('%Y-%m-%d')}.",
437
+ )
438
+ _update_learning_row(existing["id"], updates)
439
+ return {
440
+ "success": True,
441
+ "id": existing["id"],
442
+ "outcome": "duplicate_learning",
443
+ "similarity": similarity,
444
+ "updated_existing": bool(updates),
445
+ }
446
+
447
+ if similarity >= 0.58:
448
+ if "weight" in columns:
449
+ updates["weight"] = _bump_weight(existing.get("weight"), 0.25)
450
+ if "reasoning" in columns:
451
+ updates["reasoning"] = _append_note(
452
+ existing.get("reasoning", ""),
453
+ f"Deep Sleep reinforcement ({datetime.now().strftime('%Y-%m-%d')}): {title}. {content[:240]}",
454
+ )
455
+ elif "content" in columns and content and content not in str(existing.get("content", "")):
456
+ updates["content"] = _append_note(
457
+ existing.get("content", ""),
458
+ f"Reinforced by Deep Sleep: {content[:240]}",
459
+ )
460
+ _update_learning_row(existing["id"], updates)
461
+ return {
462
+ "success": True,
463
+ "id": existing["id"],
464
+ "outcome": "reinforced_learning",
465
+ "similarity": similarity,
466
+ "updated_existing": bool(updates),
467
+ }
468
+
83
469
  now = datetime.now().timestamp()
470
+ columns = _table_columns(NEXO_DB, "learnings")
471
+ payload = {
472
+ "category": category,
473
+ "title": title,
474
+ "content": content,
475
+ "created_at": now,
476
+ "updated_at": now,
477
+ }
478
+ if "reasoning" in columns:
479
+ payload["reasoning"] = "Deep Sleep v2 overnight analysis"
480
+ if "status" in columns:
481
+ payload["status"] = "active"
482
+ insert_columns = [column for column in payload if column in columns]
483
+ values = [payload[column] for column in insert_columns]
484
+
84
485
  conn = sqlite3.connect(str(NEXO_DB))
85
486
  cursor = conn.execute(
86
- "INSERT INTO learnings (category, title, content, created_at, updated_at, reasoning) VALUES (?, ?, ?, ?, ?, ?)",
87
- (category, title, content, now, now, "Deep Sleep v2 overnight analysis")
487
+ f"INSERT INTO learnings ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
488
+ values,
88
489
  )
89
490
  learning_id = cursor.lastrowid
90
491
  conn.commit()
91
492
  conn.close()
92
- return {"success": True, "id": learning_id}
493
+ return {"success": True, "id": learning_id, "outcome": "new_learning"}
93
494
  except Exception as e:
94
495
  return {"success": False, "error": str(e)}
95
496
 
96
497
 
97
- def create_followup(description: str, date: str = "") -> dict:
498
+ def create_followup(description: str, date: str = "", reasoning_note: str = "") -> dict:
98
499
  """Create a followup in nexo.db. Returns result dict."""
99
500
  if not NEXO_DB.exists():
100
501
  return {"success": False, "error": "nexo.db not found"}
101
502
  try:
503
+ matched = _find_similar_followup(description)
504
+ if matched:
505
+ return _touch_existing_followup(
506
+ matched,
507
+ description=description,
508
+ date=date,
509
+ reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
510
+ )
511
+
102
512
  now = datetime.now().timestamp()
103
513
  # Generate a deterministic ID
104
514
  fid = "NF-DS-" + hashlib.md5(description.encode()).hexdigest()[:8].upper()
515
+ columns = _table_columns(NEXO_DB, "followups")
516
+ payload = {
517
+ "id": fid,
518
+ "description": description,
519
+ "date": date,
520
+ "status": "PENDING",
521
+ "created_at": now,
522
+ "updated_at": now,
523
+ }
524
+ if "reasoning" in columns:
525
+ payload["reasoning"] = reasoning_note or "Deep Sleep v2 overnight analysis"
526
+ if "verification" in columns:
527
+ payload["verification"] = ""
528
+ insert_columns = [column for column in payload if column in columns]
529
+ values = [payload[column] for column in insert_columns]
530
+
105
531
  conn = sqlite3.connect(str(NEXO_DB))
106
532
  conn.execute(
107
- "INSERT OR IGNORE INTO followups (id, description, date, status, created_at, updated_at, reasoning) VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
108
- (fid, description, date, now, now, "Deep Sleep v2 overnight analysis")
533
+ f"INSERT OR IGNORE INTO followups ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
534
+ values,
109
535
  )
110
536
  conn.commit()
111
537
  conn.close()
112
- return {"success": True, "id": fid}
538
+ return {"success": True, "id": fid, "outcome": "new_followup"}
113
539
  except Exception as e:
114
540
  return {"success": False, "error": str(e)}
115
541
 
@@ -964,7 +1390,8 @@ def apply_action(action: dict, run_id: str) -> dict:
964
1390
  elif action_type == "followup_create":
965
1391
  result = create_followup(
966
1392
  description=content.get("description", content.get("title", "")),
967
- date=content.get("date", "")
1393
+ date=content.get("date", ""),
1394
+ reasoning_note=content.get("reasoning", content.get("why", "")),
968
1395
  )
969
1396
  log_entry["status"] = "applied" if result.get("success") else "error"
970
1397
  log_entry["details"] = result
@@ -30,6 +30,15 @@ Synthesize across all sessions:
30
30
  - Cross-domain connections where an older learning or session sample explains a current issue
31
31
  - Topics repeatedly mentioned over time but never formalized into a learning or followup
32
32
  - Project pressure that is rising because of repeated diary mentions, open followups, or adverse outcomes
33
+ - For medium/high-severity patterns, propose a concrete fix artifact:
34
+ - script
35
+ - hook
36
+ - checklist
37
+ - validation step
38
+ - workflow change
39
+ - guardrail
40
+
41
+ Do not stop at diagnosis. Turn repeated problems into concrete engineering work.
33
42
 
34
43
  ### 2. Morning Agenda
35
44
  Generate a prioritized agenda for the next morning:
@@ -126,6 +135,14 @@ Merge and deduplicate all findings into a final action list. Each action should
126
135
  - `dedupe_key`: deterministic key for idempotency
127
136
  - `content`: the actual data to write
128
137
 
138
+ When generating `followup_create`, prefer descriptions that start with a concrete verb and include the deliverable:
139
+ - "Add a pre-release validation script ..."
140
+ - "Implement a guard hook that ..."
141
+ - "Create a checklist for ..."
142
+ - "Write a watchdog check that ..."
143
+
144
+ Avoid vague followups that merely restate the diagnosis.
145
+
129
146
  ## Output Format
130
147
 
131
148
  Return ONLY valid JSON. No markdown code fences. No explanation text.
@@ -140,6 +157,12 @@ Return ONLY valid JSON. No markdown code fences. No explanation text.
140
157
  "pattern": "Description of the pattern",
141
158
  "sessions": ["session1.jsonl", "session2.jsonl"],
142
159
  "severity": "low|medium|high",
160
+ "proposed_fix": {
161
+ "title": "Short concrete fix title",
162
+ "description": "Concrete engineering change to make",
163
+ "deliverable": "script|hook|checklist|workflow|guardrail",
164
+ "confidence": 0.0
165
+ },
143
166
  "evidence": [
144
167
  {"type": "transcript", "session_id": "...", "message_index": 42, "quote": "..."}
145
168
  ]
@@ -14,6 +14,7 @@ import json
14
14
  import os
15
15
  import subprocess
16
16
  import sys
17
+ import hashlib
17
18
  from datetime import datetime
18
19
  from pathlib import Path
19
20
 
@@ -28,6 +29,7 @@ if str(NEXO_CODE) not in sys.path:
28
29
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
29
30
 
30
31
  CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
32
+ ACTION_VERBS = {"add", "implement", "create", "write", "build", "enforce", "automate", "validate", "guard", "fix", "review"}
31
33
 
32
34
 
33
35
  def extract_json_from_response(text: str) -> dict | None:
@@ -86,6 +88,96 @@ def collect_skill_runtime_candidates(target_date: str) -> tuple[Path, dict]:
86
88
  return output_file, payload
87
89
 
88
90
 
91
+ def _normalize_action_text(value: str) -> str:
92
+ return " ".join(str(value or "").strip().lower().split())
93
+
94
+
95
+ def _looks_concrete_action(text: str) -> bool:
96
+ words = {word.strip(".,:;()[]{}").lower() for word in str(text or "").split()}
97
+ return bool(words & ACTION_VERBS)
98
+
99
+
100
+ def _pattern_followup_from_fix(pattern: dict) -> dict | None:
101
+ severity = str(pattern.get("severity", "") or "").lower()
102
+ sessions = pattern.get("sessions", []) or []
103
+ if severity not in {"medium", "high"} and len(sessions) < 2:
104
+ return None
105
+
106
+ proposed_fix = pattern.get("proposed_fix") or {}
107
+ pattern_text = str(pattern.get("pattern", "") or "").strip()
108
+ title = str(proposed_fix.get("title", "") or "").strip()
109
+ description = str(proposed_fix.get("description", "") or "").strip()
110
+ deliverable = str(proposed_fix.get("deliverable", "") or proposed_fix.get("artifact", "") or "").strip()
111
+
112
+ if title and description:
113
+ if _looks_concrete_action(description):
114
+ followup_description = description
115
+ else:
116
+ followup_description = f"{title}: {description}"
117
+ elif description:
118
+ followup_description = description
119
+ elif title:
120
+ followup_description = title
121
+ elif pattern_text:
122
+ followup_description = (
123
+ f"Implement a concrete guardrail for recurring issue: {pattern_text}. "
124
+ "Deliverable should be a script, hook, checklist, or automated validation that prevents the same failure from repeating."
125
+ )
126
+ else:
127
+ return None
128
+
129
+ if deliverable and deliverable.lower() not in followup_description.lower():
130
+ followup_description = f"{followup_description} Deliverable: {deliverable}."
131
+ if not _looks_concrete_action(followup_description):
132
+ followup_description = f"Implement this fix: {followup_description}"
133
+
134
+ return {
135
+ "action_type": "followup_create",
136
+ "action_class": "auto_apply" if severity == "high" else "draft_for_morning",
137
+ "confidence": round(max(float(proposed_fix.get("confidence", 0.0) or 0.0), 0.86 if severity == "high" else 0.78), 2),
138
+ "impact": "high" if severity == "high" else "medium",
139
+ "reversibility": "reversible",
140
+ "evidence": pattern.get("evidence", []) or [],
141
+ "dedupe_key": "engineering-fix:" + hashlib.md5(
142
+ _normalize_action_text(followup_description).encode("utf-8")
143
+ ).hexdigest()[:16],
144
+ "content": {
145
+ "title": title or f"Engineering fix for: {pattern_text[:90]}",
146
+ "description": followup_description,
147
+ "date": "",
148
+ "reasoning": f"Deep Sleep engineering followup from recurring pattern: {pattern_text}",
149
+ },
150
+ }
151
+
152
+
153
+ def backfill_engineering_actions(payload: dict) -> dict:
154
+ if not isinstance(payload, dict):
155
+ return payload
156
+ actions = payload.get("actions")
157
+ if not isinstance(actions, list):
158
+ actions = []
159
+ payload["actions"] = actions
160
+
161
+ existing_keys = {str(action.get("dedupe_key", "") or "") for action in actions}
162
+ existing_descriptions = {
163
+ _normalize_action_text(action.get("content", {}).get("description", ""))
164
+ for action in actions
165
+ if isinstance(action, dict)
166
+ }
167
+
168
+ for pattern in payload.get("cross_session_patterns", []) or []:
169
+ action = _pattern_followup_from_fix(pattern)
170
+ if not action:
171
+ continue
172
+ description = _normalize_action_text(action["content"]["description"])
173
+ if action["dedupe_key"] in existing_keys or description in existing_descriptions:
174
+ continue
175
+ actions.append(action)
176
+ existing_keys.add(action["dedupe_key"])
177
+ existing_descriptions.add(description)
178
+ return payload
179
+
180
+
89
181
  def main():
90
182
  target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
91
183
 
@@ -177,6 +269,8 @@ def main():
177
269
  print(f"[synthesize] Failed to parse JSON. Raw output saved to {debug_file}", file=sys.stderr)
178
270
  sys.exit(1)
179
271
 
272
+ parsed = backfill_engineering_actions(parsed)
273
+
180
274
  # Write synthesis output
181
275
  output_file = DEEP_SLEEP_DIR / f"{target_date}-synthesis.json"
182
276
  with open(output_file, "w") as f:
@@ -16,6 +16,36 @@ from pathlib import Path
16
16
 
17
17
 
18
18
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
20
+
21
+
22
+ def _load_schedule() -> dict:
23
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
24
+ if not schedule_path.is_file():
25
+ return {}
26
+ try:
27
+ return json.loads(schedule_path.read_text())
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def _resolve_automation_backend() -> str:
33
+ data = _load_schedule()
34
+ return str(data.get("automation_backend", "claude_code") or "claude_code")
35
+
36
+
37
+ def _load_bootstrap_prompt() -> str:
38
+ backend = _resolve_automation_backend()
39
+ if backend == "codex":
40
+ path = Path.home() / ".codex" / "AGENTS.md"
41
+ else:
42
+ path = Path.home() / ".claude" / "CLAUDE.md"
43
+ if not path.is_file():
44
+ return ""
45
+ try:
46
+ return path.read_text()
47
+ except Exception:
48
+ return ""
19
49
 
20
50
 
21
51
  def run_nexo(args: list[str]) -> str:
@@ -57,6 +87,9 @@ def run_automation_text(
57
87
  model: str = "",
58
88
  reasoning_effort: str = "",
59
89
  cwd: str = "",
90
+ allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
91
+ append_system_prompt: str = "",
92
+ include_bootstrap: bool = True,
60
93
  ) -> str:
61
94
  """Run the configured NEXO automation backend and return text output.
62
95
 
@@ -75,6 +108,17 @@ def run_automation_text(
75
108
  cmd.extend(["--reasoning-effort", reasoning_effort])
76
109
  if cwd:
77
110
  cmd.extend(["--cwd", cwd])
111
+ merged_system_prompt = []
112
+ if include_bootstrap:
113
+ bootstrap = _load_bootstrap_prompt()
114
+ if bootstrap:
115
+ merged_system_prompt.append(bootstrap)
116
+ if append_system_prompt:
117
+ merged_system_prompt.append(append_system_prompt)
118
+ if merged_system_prompt:
119
+ cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
120
+ if allowed_tools:
121
+ cmd.extend(["--allowed-tools", allowed_tools])
78
122
 
79
123
  env = os.environ.copy()
80
124
  env.setdefault("NEXO_HOME", str(NEXO_HOME))