nexo-brain 7.30.0 → 7.30.2

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": "7.30.0",
3
+ "version": "7.30.2",
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
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.30.0` is the current packaged-runtime line. Minor release over v7.29.0 - morning briefing preferences now support weather/news, selectable weekdays, safer SMTP certificates, Opus 4.8 defaults, and an agent-facing preference catalog so NEXO can explain or change supported settings by chat.
21
+ Version `7.30.2` is the current packaged-runtime line. Patch release over v7.30.1 - Deep Sleep now fails closed on partial nightly runs, carries complete learning context into synthesis, writes an explicit agent start packet, and creates governed followups with verification, priority, owner, and date fields.
22
+
23
+ Previously in `7.30.1`: patch release over v7.30.0 - morning briefings now behave more like a start-of-day assistant, explain news/weather as verified optional sources, and keep user type inference inside NEXO instead of asking the user to choose a role manually.
24
+
25
+ Previously in `7.30.0`: minor release over v7.29.0 - morning briefing preferences now support weather/news, selectable weekdays, safer SMTP certificates, Opus 4.8 defaults, and an agent-facing preference catalog so NEXO can explain or change supported settings by chat.
22
26
 
23
27
  Previously in `7.29.0`: minor release over v7.28.0 - the morning briefing now has structured content preferences, Desktop-facing briefing presentation state, and per-account email signature support.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.0",
3
+ "version": "7.30.2",
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",
@@ -83,14 +83,14 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
83
83
  "type": "boolean",
84
84
  "label": "News",
85
85
  "default": False,
86
- "help": "A short set of public headlines from the configured news feed. If the feed is unreachable, NEXO simply says it was unavailable.",
86
+ "help": "A short set of current public headlines from the configured news feed, included only when the source can be verified.",
87
87
  },
88
88
  {
89
89
  "id": "weather",
90
90
  "type": "boolean",
91
91
  "label": "Weather",
92
92
  "default": True,
93
- "help": "Today's weather from the location saved in Desktop or your residence in the profile.",
93
+ "help": "Today's weather from the location saved in Desktop or your residence in the profile, included only when the forecast can be verified.",
94
94
  },
95
95
  ],
96
96
  },
@@ -348,8 +348,12 @@ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
348
348
  return (
349
349
  "\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
350
350
  f"{compact}\n"
351
+ "Morning briefing intent: act like a professional personal assistant preparing the operator for the day. "
352
+ "Do not merely list available records; filter, rank, and explain what deserves attention first.\n"
353
+ "Adapt the emphasis from the operator profile, role, recent activity, and context. "
354
+ "Do not ask the user to choose a user type manually and do not assume a profession unless the context supports it.\n"
351
355
  "Use these preferences to decide what to include, omit, and emphasize. "
352
- "Disabled/unavailable data sources must not be invented.\n"
356
+ "Disabled/unavailable data sources must not be invented; news and weather require verified collected data.\n"
353
357
  )
354
358
 
355
359
 
@@ -666,6 +666,9 @@ def _get_reranker():
666
666
  """Lazy-load cross-encoder reranking model."""
667
667
  global _reranker
668
668
  if _reranker is None:
669
+ if _model_download_disabled():
670
+ _reranker = False
671
+ return None
669
672
  try:
670
673
  from local_models import build_fastembed_reranker
671
674
 
@@ -418,6 +418,10 @@ def _touch_existing_followup(
418
418
  date: str = "",
419
419
  reasoning_note: str = "",
420
420
  status: str = "",
421
+ verification: str = "",
422
+ priority: str = "",
423
+ internal: object = None,
424
+ owner: str = "",
421
425
  ) -> dict:
422
426
  cols = _table_columns(NEXO_DB, "followups")
423
427
  if not cols:
@@ -433,6 +437,15 @@ def _touch_existing_followup(
433
437
  desired_status = (status or "").strip()
434
438
  if desired_status and "status" in cols and desired_status != str(existing.get("status", "") or ""):
435
439
  updates["status"] = desired_status
440
+ existing_verification = str(existing.get("verification", "") or "").strip()
441
+ if verification and "verification" in cols and not existing_verification:
442
+ updates["verification"] = verification
443
+ if priority and "priority" in cols and priority != str(existing.get("priority", "") or ""):
444
+ updates["priority"] = priority
445
+ if internal is not None and "internal" in cols:
446
+ updates["internal"] = 1 if str(internal).strip().lower() in {"1", "true", "yes", "on"} else 0
447
+ if owner and "owner" in cols and owner != str(existing.get("owner", "") or ""):
448
+ updates["owner"] = owner
436
449
  note = reasoning_note or "Deep Sleep matched this followup semantically."
437
450
  changed = False
438
451
  if updates:
@@ -693,12 +706,52 @@ def add_learning(category: str, title: str, content: str) -> dict:
693
706
  return {"success": False, "error": str(e)}
694
707
 
695
708
 
696
- def create_followup(description: str, date: str = "", reasoning_note: str = "", status: str = "PENDING") -> dict:
709
+ def _default_followup_date(date: str, *, status: str = "") -> str:
710
+ clean = str(date or "").strip()
711
+ if clean or str(status or "").strip().lower() == "archived":
712
+ return clean
713
+ return (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
714
+
715
+
716
+ def _default_followup_verification(description: str, verification: str = "") -> str:
717
+ clean = str(verification or "").strip()
718
+ if clean:
719
+ return clean
720
+ description_text = str(description or "").strip()
721
+ if not description_text:
722
+ return "Verify the Deep Sleep followup has a concrete deliverable or close it as invalid."
723
+ return f"Verify completion evidence for: {description_text[:180]}"
724
+
725
+
726
+ def _normalize_followup_priority(priority: str = "", impact: str = "") -> str:
727
+ clean = str(priority or "").strip().lower()
728
+ if clean in {"critical", "high", "medium", "low"}:
729
+ return clean
730
+ impact_clean = str(impact or "").strip().lower()
731
+ if impact_clean in {"critical", "high", "medium", "low"}:
732
+ return impact_clean
733
+ return "medium"
734
+
735
+
736
+ def create_followup(
737
+ description: str,
738
+ date: str = "",
739
+ reasoning_note: str = "",
740
+ status: str = "PENDING",
741
+ verification: str = "",
742
+ priority: str = "",
743
+ internal: object = 1,
744
+ owner: str = "agent",
745
+ ) -> dict:
697
746
  """Create a followup in nexo.db. Returns result dict."""
698
747
  if not NEXO_DB.exists():
699
748
  return {"success": False, "error": "nexo.db not found"}
700
749
  try:
701
750
  desired_status = (status or "PENDING").strip() or "PENDING"
751
+ desired_date = _default_followup_date(date, status=desired_status)
752
+ desired_verification = _default_followup_verification(description, verification)
753
+ desired_priority = _normalize_followup_priority(priority)
754
+ desired_owner = (owner or "agent").strip() or "agent"
702
755
  is_abandoned = description.strip().startswith("[Abandoned]")
703
756
  if not is_abandoned:
704
757
  matched = _find_similar_followup(description)
@@ -706,9 +759,13 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "",
706
759
  return _touch_existing_followup(
707
760
  matched,
708
761
  description=description,
709
- date=date,
762
+ date=desired_date,
710
763
  reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
711
764
  status=desired_status,
765
+ verification=desired_verification,
766
+ priority=desired_priority,
767
+ internal=internal,
768
+ owner=desired_owner,
712
769
  )
713
770
 
714
771
  # Generate a deterministic ID — content fingerprint, not security-sensitive.
@@ -718,19 +775,26 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "",
718
775
  return _touch_existing_followup(
719
776
  existing,
720
777
  description=description,
721
- date=date,
778
+ date=desired_date,
722
779
  reasoning_note=reasoning_note or "Deep Sleep revisited this deterministic followup.",
723
780
  status=desired_status,
781
+ verification=desired_verification,
782
+ priority=desired_priority,
783
+ internal=internal,
784
+ owner=desired_owner,
724
785
  )
725
786
 
726
787
  followup_result = nexo_db.create_followup(
727
788
  id=fid,
728
789
  description=description,
729
- date=date or None,
730
- verification="",
790
+ date=desired_date or None,
791
+ verification=desired_verification,
731
792
  status=desired_status,
732
793
  reasoning=reasoning_note or "Deep Sleep v2 overnight analysis",
733
794
  recurrence=None,
795
+ priority=desired_priority,
796
+ internal=internal,
797
+ owner=desired_owner,
734
798
  )
735
799
  if followup_result.get("error"):
736
800
  return {"success": False, "error": followup_result["error"]}
@@ -2113,10 +2177,15 @@ def apply_action(action: dict, run_id: str) -> dict:
2113
2177
  log_entry["details"] = result
2114
2178
 
2115
2179
  elif action_type == "followup_create":
2180
+ description = content.get("description", content.get("title", ""))
2116
2181
  result = create_followup(
2117
- description=content.get("description", content.get("title", "")),
2182
+ description=description,
2118
2183
  date=content.get("date", ""),
2119
2184
  reasoning_note=content.get("reasoning", content.get("why", "")),
2185
+ verification=content.get("verification", content.get("success_signal", "")),
2186
+ priority=_normalize_followup_priority(content.get("priority", ""), action.get("impact", "")),
2187
+ internal=content.get("internal", 1),
2188
+ owner=content.get("owner", "agent"),
2120
2189
  )
2121
2190
  log_entry["status"] = "applied" if result.get("success") else "error"
2122
2191
  log_entry["details"] = result
@@ -190,6 +190,62 @@ def backfill_engineering_actions(payload: dict) -> dict:
190
190
  return payload
191
191
 
192
192
 
193
+ def _compact_action_title(action: dict) -> str:
194
+ content = action.get("content") if isinstance(action, dict) else {}
195
+ if isinstance(content, dict):
196
+ return str(content.get("title") or content.get("description") or "").strip()
197
+ return str(content or "").strip()
198
+
199
+
200
+ def build_agent_start_packet(payload: dict, target_date: str) -> dict:
201
+ """Build a compact handoff packet for the next interactive agent startup."""
202
+ agenda = payload.get("morning_agenda") if isinstance(payload, dict) else []
203
+ packets = payload.get("context_packets") if isinstance(payload, dict) else []
204
+ actions = payload.get("actions") if isinstance(payload, dict) else []
205
+
206
+ review_items = []
207
+ if isinstance(actions, list):
208
+ for action in actions:
209
+ if not isinstance(action, dict):
210
+ continue
211
+ if action.get("action_class") != "draft_for_morning":
212
+ continue
213
+ title = _compact_action_title(action)
214
+ if title:
215
+ review_items.append(
216
+ {
217
+ "title": title,
218
+ "action_type": action.get("action_type", ""),
219
+ "impact": action.get("impact", ""),
220
+ "confidence": action.get("confidence", 0),
221
+ }
222
+ )
223
+
224
+ return {
225
+ "date": str(payload.get("date") or target_date),
226
+ "generated_at": datetime.now().isoformat(),
227
+ "source": "deep-sleep/synthesize",
228
+ "summary": str(payload.get("summary") or "").strip(),
229
+ "agenda": agenda[:5] if isinstance(agenda, list) else [],
230
+ "context_packets": packets[:5] if isinstance(packets, list) else [],
231
+ "review_items": review_items[:5],
232
+ "counts": {
233
+ "actions": len(actions) if isinstance(actions, list) else 0,
234
+ "agenda": len(agenda) if isinstance(agenda, list) else 0,
235
+ "context_packets": len(packets) if isinstance(packets, list) else 0,
236
+ "review_items": len(review_items),
237
+ },
238
+ }
239
+
240
+
241
+ def write_agent_start_packet(payload: dict, target_date: str) -> Path:
242
+ """Persist a startup-ready Deep Sleep packet next to synthesis outputs."""
243
+ packet = build_agent_start_packet(payload, target_date)
244
+ packet_file = DEEP_SLEEP_DIR / f"{target_date}-agent-start-packet.json"
245
+ packet_file.write_text(json.dumps(packet, indent=2, ensure_ascii=False), encoding="utf-8")
246
+ return packet_file
247
+
248
+
193
249
  def main():
194
250
  target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
195
251
 
@@ -226,7 +282,9 @@ def main():
226
282
  output_file = DEEP_SLEEP_DIR / f"{target_date}-synthesis.json"
227
283
  with open(output_file, "w") as f:
228
284
  json.dump(output, f, indent=2, ensure_ascii=False)
285
+ packet_file = write_agent_start_packet(output, target_date)
229
286
  print(f"[synthesize] Output: {output_file}")
287
+ print(f"[synthesize] Agent start packet: {packet_file}")
230
288
  return
231
289
 
232
290
  # Build prompt
@@ -288,6 +346,7 @@ def main():
288
346
  output_file = DEEP_SLEEP_DIR / f"{target_date}-synthesis.json"
289
347
  with open(output_file, "w") as f:
290
348
  json.dump(parsed, f, indent=2, ensure_ascii=False)
349
+ packet_file = write_agent_start_packet(parsed, target_date)
291
350
 
292
351
  n_actions = len(parsed.get("actions", []))
293
352
  n_patterns = len(parsed.get("cross_session_patterns", []))
@@ -300,6 +359,7 @@ def main():
300
359
  print(f" Morning agenda items: {n_agenda}")
301
360
  print(f" Context packets: {n_packets}")
302
361
  print(f"[synthesize] Output: {output_file}")
362
+ print(f"[synthesize] Agent start packet: {packet_file}")
303
363
 
304
364
  except AutomationBackendUnavailableError as exc:
305
365
  print(f"[synthesize] Automation backend unavailable: {exc}", file=sys.stderr)
@@ -63,7 +63,10 @@ fi
63
63
 
64
64
  # Phase 2: Extract findings per session (configured automation backend)
65
65
  log "Phase 2: Extracting findings from $SESSIONS sessions..."
66
- python3 "$SCRIPT_DIR/deep-sleep/extract.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
66
+ if ! python3 "$SCRIPT_DIR/deep-sleep/extract.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1; then
67
+ log "Extraction failed. Watermark NOT updated (will retry next run)."
68
+ exit 1
69
+ fi
67
70
 
68
71
  if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-extractions.json" ]; then
69
72
  log "Extraction failed. Watermark NOT updated (will retry next run)."
@@ -72,16 +75,22 @@ fi
72
75
 
73
76
  # Phase 3: Cross-session synthesis (configured automation backend, one call)
74
77
  log "Phase 3: Synthesizing cross-session findings..."
75
- python3 "$SCRIPT_DIR/deep-sleep/synthesize.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
78
+ if ! python3 "$SCRIPT_DIR/deep-sleep/synthesize.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1; then
79
+ log "Synthesis failed. Watermark NOT updated (will retry next run)."
80
+ exit 1
81
+ fi
76
82
 
77
83
  if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-synthesis.json" ]; then
78
- log "Synthesis failed. Falling back to extractions only."
79
- cp "$DEEP_SLEEP_DIR/$RUN_ID-extractions.json" "$DEEP_SLEEP_DIR/$RUN_ID-synthesis.json"
84
+ log "Synthesis output missing. Watermark NOT updated (will retry next run)."
85
+ exit 1
80
86
  fi
81
87
 
82
88
  # Phase 4: Apply findings
83
89
  log "Phase 4: Applying findings..."
84
- python3 "$SCRIPT_DIR/deep-sleep/apply_findings.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
90
+ if ! python3 "$SCRIPT_DIR/deep-sleep/apply_findings.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1; then
91
+ log "Apply failed. Watermark NOT updated (will retry next run)."
92
+ exit 1
93
+ fi
85
94
 
86
95
  # Update watermark on success
87
96
  echo "$UNTIL" > "$WATERMARK_FILE"
@@ -63,6 +63,11 @@ COMPRESSED_MEMORIES_DIR = BRAIN_DIR / "compressed_memories"
63
63
  HEARTBEAT_LOG = COORD_DIR / "heartbeat-log.json"
64
64
  REFLECTION_LOG = COORD_DIR / "reflection-log.json"
65
65
  SLEEP_LOG = COORD_DIR / "sleep-log.json"
66
+ SLEEP_HEALTH_FILE = COORD_DIR / "sleep-health.json"
67
+ LEARNINGS_DUMP_FILE = COORD_DIR / "sleep-learnings-dump.json"
68
+ LEARNINGS_CHUNKS_DIR = COORD_DIR / "sleep-learnings-chunks"
69
+ MIN_LEARNING_COVERAGE_PCT = 95.0
70
+ LEARNING_CHUNK_MAX_CHARS = 50000
66
71
 
67
72
  MEMORY_MD = paths.memory_dir() / "MEMORY.md"
68
73
  NEXO_DB = paths.db_path()
@@ -209,6 +214,41 @@ def append_sleep_log(entry: dict):
209
214
  save_json(SLEEP_LOG, entries)
210
215
 
211
216
 
217
+ def write_sleep_health(
218
+ run_log: dict,
219
+ state: dict,
220
+ *,
221
+ status: str,
222
+ error: str = "",
223
+ actions: dict | None = None,
224
+ ) -> dict:
225
+ """Write a small machine-readable health file for startup/briefings."""
226
+ stage_b = run_log.get("stage_b") if isinstance(run_log, dict) else {}
227
+ if not isinstance(stage_b, dict):
228
+ stage_b = {}
229
+ coverage = {}
230
+ if isinstance(actions, dict):
231
+ coverage = actions.get("coverage") or {}
232
+ if not coverage and isinstance(stage_b, dict):
233
+ coverage = stage_b.get("coverage") or {}
234
+
235
+ health = {
236
+ "date": str(TODAY),
237
+ "status": status,
238
+ "generated_at": datetime.now().isoformat(),
239
+ "error": error,
240
+ "learnings_total": len(state.get("learnings", []) if isinstance(state, dict) else []),
241
+ "memory_md_lines": state.get("memory_md_lines", 0) if isinstance(state, dict) else 0,
242
+ "old_observations": state.get("claude_mem_old", 0) if isinstance(state, dict) else 0,
243
+ "stage_a": run_log.get("stage_a") if isinstance(run_log, dict) else None,
244
+ "stage_b": stage_b,
245
+ "coverage": coverage,
246
+ "last_run_marked_complete": status == "ok",
247
+ }
248
+ save_json(SLEEP_HEALTH_FILE, health)
249
+ return health
250
+
251
+
212
252
  # ─── Stage A: Mechanical cleanup (UNCHANGED from v1) ─────────────────────────
213
253
 
214
254
  def stage_a_cleanup() -> dict:
@@ -388,13 +428,108 @@ def should_dream(state: dict) -> bool:
388
428
  )
389
429
 
390
430
 
431
+ def write_learning_context(state: dict) -> dict:
432
+ """Persist all learnings to files so Stage B never receives a truncated list."""
433
+ learnings = state.get("learnings", []) if isinstance(state, dict) else []
434
+ LEARNINGS_CHUNKS_DIR.mkdir(parents=True, exist_ok=True)
435
+
436
+ # Remove stale chunks for today's run before rewriting them.
437
+ today_prefix = f"{TODAY}-chunk-"
438
+ for old_chunk in LEARNINGS_CHUNKS_DIR.glob(f"{today_prefix}*.json"):
439
+ try:
440
+ old_chunk.unlink()
441
+ except Exception:
442
+ pass
443
+
444
+ chunks: list[list[dict]] = []
445
+ current: list[dict] = []
446
+ current_chars = 2
447
+ for learning in learnings:
448
+ rendered = json.dumps(learning, ensure_ascii=False, separators=(",", ":"))
449
+ if current and current_chars + len(rendered) + 1 > LEARNING_CHUNK_MAX_CHARS:
450
+ chunks.append(current)
451
+ current = []
452
+ current_chars = 2
453
+ current.append(learning)
454
+ current_chars += len(rendered) + 1
455
+ if current or not chunks:
456
+ chunks.append(current)
457
+
458
+ chunk_files: list[str] = []
459
+ for index, chunk in enumerate(chunks, start=1):
460
+ chunk_path = LEARNINGS_CHUNKS_DIR / f"{TODAY}-chunk-{index:03d}.json"
461
+ save_json(
462
+ chunk_path,
463
+ {
464
+ "date": str(TODAY),
465
+ "chunk_index": index,
466
+ "chunk_count": len(chunks),
467
+ "learnings_total_declared": len(learnings),
468
+ "learnings_in_chunk": len(chunk),
469
+ "learnings": chunk,
470
+ },
471
+ )
472
+ chunk_files.append(str(chunk_path))
473
+
474
+ coverage = {
475
+ "learnings_visible_count": len(learnings),
476
+ "learnings_total_declared": len(learnings),
477
+ "coverage_pct": 100.0 if learnings or len(learnings) == 0 else 0.0,
478
+ "source_file": str(LEARNINGS_DUMP_FILE),
479
+ "chunk_count": len(chunk_files),
480
+ }
481
+ save_json(
482
+ LEARNINGS_DUMP_FILE,
483
+ {
484
+ "date": str(TODAY),
485
+ "generated_at": datetime.now().isoformat(),
486
+ "coverage": coverage,
487
+ "chunk_files": chunk_files,
488
+ "learnings": learnings,
489
+ },
490
+ )
491
+ return {
492
+ "dump_file": str(LEARNINGS_DUMP_FILE),
493
+ "chunk_files": chunk_files,
494
+ "coverage": coverage,
495
+ }
496
+
497
+
498
+ def validate_actions_coverage(actions: dict, state: dict) -> tuple[bool, str]:
499
+ """Fail closed when Stage B did not inspect nearly all active learnings."""
500
+ expected_total = len(state.get("learnings", []) if isinstance(state, dict) else [])
501
+ if expected_total == 0:
502
+ return True, "no active learnings"
503
+
504
+ coverage = actions.get("coverage") if isinstance(actions, dict) else None
505
+ if not isinstance(coverage, dict):
506
+ return False, "sleep-actions.json is missing coverage metadata"
507
+
508
+ try:
509
+ visible_count = int(coverage.get("learnings_visible_count", 0) or 0)
510
+ except Exception:
511
+ visible_count = 0
512
+ try:
513
+ declared_total = int(coverage.get("learnings_total_declared", 0) or 0)
514
+ except Exception:
515
+ declared_total = 0
516
+ try:
517
+ coverage_pct = float(coverage.get("coverage_pct", 0.0) or 0.0)
518
+ except Exception:
519
+ coverage_pct = 0.0
520
+
521
+ if declared_total != expected_total:
522
+ return False, f"coverage declared {declared_total} learnings but state has {expected_total}"
523
+ if visible_count < expected_total:
524
+ return False, f"coverage only saw {visible_count}/{expected_total} learnings"
525
+ if coverage_pct < MIN_LEARNING_COVERAGE_PCT:
526
+ return False, f"coverage {coverage_pct:.1f}% is below {MIN_LEARNING_COVERAGE_PCT:.1f}%"
527
+ return True, "coverage ok"
528
+
529
+
391
530
  def dream(state: dict) -> dict:
392
531
  """The brain dreams — CLI does the intelligent work."""
393
-
394
- # Truncate learnings JSON if too large
395
- learnings_json = json.dumps(state["learnings"], ensure_ascii=False, indent=1)
396
- if len(learnings_json) > 15000:
397
- learnings_json = learnings_json[:15000] + "\n... (truncated)"
532
+ learning_context = write_learning_context(state)
398
533
 
399
534
  tasks = []
400
535
 
@@ -410,12 +545,30 @@ Write your findings to {COORD_DIR}/sleep-report.md with sections:
410
545
  - "## Stale candidates" — IDs of learnings that may be obsolete
411
546
 
412
547
  Also write a machine-readable file {COORD_DIR}/sleep-actions.json:
413
- {{"archive_ids": [1, 2, 3], "contradiction_pairs": [[4, 5]], "stale_ids": [6, 7]}}
548
+ {{
549
+ "archive_ids": [1, 2, 3],
550
+ "contradiction_pairs": [[4, 5]],
551
+ "stale_ids": [6, 7],
552
+ "coverage": {{
553
+ "learnings_visible_count": {len(state['learnings'])},
554
+ "learnings_total_declared": {len(state['learnings'])},
555
+ "coverage_pct": 100.0,
556
+ "source_file": "{learning_context['dump_file']}",
557
+ "chunk_files_read": {json.dumps(learning_context['chunk_files'], ensure_ascii=False)}
558
+ }}
559
+ }}
414
560
 
415
561
  The wrapper will execute the actual DB operations based on this JSON.
562
+ The wrapper will fail closed if coverage is missing or below {MIN_LEARNING_COVERAGE_PCT:.0f}%.
563
+
564
+ LEARNINGS SOURCE:
565
+ Read the full learning list from {learning_context['dump_file']}.
566
+ If the file is too large for one read, read every chunk listed below and merge them by ID:
567
+ {json.dumps(learning_context['chunk_files'], ensure_ascii=False, indent=2)}
416
568
 
417
- LEARNINGS:
418
- {learnings_json}""")
569
+ Do not infer duplicates/stale items from a partial list. If you cannot read at least
570
+ {MIN_LEARNING_COVERAGE_PCT:.0f}% of the declared learnings, write empty archive/stale arrays
571
+ and include coverage.blocking_reason explaining the read failure.""")
419
572
 
420
573
  if state["memory_md_lines"] > 170:
421
574
  tasks.append(f"""TASK 2: MEMORY.MD COMPRESSION ({state['memory_md_lines']} lines, limit 200)
@@ -461,20 +614,25 @@ The wrapper will handle the actual DB cleanup safely.""")
461
614
 
462
615
  if result.returncode != 0:
463
616
  log(f"Stage B: CLI error ({result.returncode}): {(result.stderr or '')[:300]}")
464
- return {"error": result.returncode}
617
+ return {"error": result.returncode, "learning_context": learning_context}
465
618
 
466
619
  log(f"Stage B: Dreaming complete. Output: {len(result.stdout or '')} chars")
467
- return {"ok": True, "output_len": len(result.stdout or "")}
620
+ return {
621
+ "ok": True,
622
+ "output_len": len(result.stdout or ""),
623
+ "learning_context": learning_context,
624
+ "coverage": learning_context["coverage"],
625
+ }
468
626
 
469
627
  except AutomationBackendUnavailableError as e:
470
628
  log(f"Stage B: automation backend unavailable: {e}")
471
- return {"error": "backend-unavailable"}
629
+ return {"error": "backend-unavailable", "learning_context": learning_context}
472
630
  except subprocess.TimeoutExpired:
473
- log("Stage B: CLI timed out (600s)")
474
- return {"error": "timeout"}
631
+ log(f"Stage B: CLI timed out ({AUTOMATION_SUBPROCESS_TIMEOUT}s)")
632
+ return {"error": "timeout", "learning_context": learning_context}
475
633
  except Exception as e:
476
634
  log(f"Stage B: Exception: {e}")
477
- return {"error": str(e)}
635
+ return {"error": str(e), "learning_context": learning_context}
478
636
 
479
637
 
480
638
  def execute_dream_actions(actions: dict, state: dict):
@@ -573,8 +731,11 @@ def main():
573
731
  start_phase = get_interrupted_phase()
574
732
 
575
733
  run_log = {"date": str(TODAY), "started": TIMESTAMP,
576
- "stage_a": None, "stage_b": None, "completed": None}
734
+ "stage_a": None, "stage_b": None, "completed": None,
735
+ "marked_complete": False}
577
736
  sleep_had_errors = False
737
+ sleep_error = ""
738
+ actions = None
578
739
 
579
740
  # Stage A: Housekeeping (mechanical)
580
741
  if start_phase == "stage_a":
@@ -598,22 +759,49 @@ def main():
598
759
  log(f"Stage B: Dreaming failed ({dream_result['error']}). "
599
760
  "Stage A cleanup completed successfully. Not marking catchup to allow retry.")
600
761
  sleep_had_errors = True
762
+ sleep_error = str(dream_result["error"])
601
763
  else:
602
764
  # Stage B2: Execute actions from CLI output
603
765
  actions_file = COORD_DIR / "sleep-actions.json"
604
766
  if actions_file.exists():
605
767
  try:
606
768
  actions = json.loads(actions_file.read_text())
607
- execute_dream_actions(actions, state)
769
+ coverage_ok, coverage_reason = validate_actions_coverage(actions, state)
770
+ run_log["stage_b"]["actions_file"] = str(actions_file)
771
+ run_log["stage_b"]["coverage_ok"] = coverage_ok
772
+ run_log["stage_b"]["coverage_reason"] = coverage_reason
773
+ if not coverage_ok:
774
+ log(f"Stage B2: Refusing dream actions: {coverage_reason}")
775
+ sleep_had_errors = True
776
+ sleep_error = coverage_reason
777
+ else:
778
+ execute_dream_actions(actions, state)
608
779
  except Exception as e:
609
780
  log(f"Stage B2: Error executing actions: {e}")
781
+ sleep_had_errors = True
782
+ sleep_error = str(e)
783
+ else:
784
+ sleep_had_errors = True
785
+ sleep_error = f"missing actions file: {actions_file}"
786
+ run_log["stage_b"]["error"] = sleep_error
787
+ log(f"Stage B2: {sleep_error}")
610
788
  else:
611
789
  log("Brain is clean -- no dreaming needed.")
612
790
  run_log["stage_b"] = {"skipped": True}
613
791
 
614
792
  # Done
615
793
  run_log["completed"] = datetime.now().strftime("%Y-%m-%d %H:%M")
794
+ if sleep_had_errors:
795
+ run_log["status"] = "failed"
796
+ write_sleep_health(run_log, state, status="failed", error=sleep_error, actions=actions)
797
+ append_sleep_log(run_log)
798
+ log("NEXO Sleep v2 failed during Stage B; not marking today complete so the next trigger retries.")
799
+ sys.exit(1)
800
+
616
801
  mark_complete()
802
+ run_log["marked_complete"] = True
803
+ run_log["status"] = "ok"
804
+ write_sleep_health(run_log, state, status="ok", actions=actions)
617
805
  append_sleep_log(run_log)
618
806
  log(f"NEXO Sleep v2 complete at {run_log['completed']}")
619
807
 
@@ -634,6 +634,192 @@ def _read_session_briefing_excerpt(max_lines: int = 6) -> tuple[str, str]:
634
634
  return excerpt, str(briefing_path)
635
635
 
636
636
 
637
+ def _read_sleep_health_warning() -> tuple[list[str], str]:
638
+ """Return a compact warning when nightly sleep failed or degraded."""
639
+ health_path = paths.coordination_dir() / "sleep-health.json"
640
+ try:
641
+ payload = json.loads(health_path.read_text(encoding="utf-8"))
642
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
643
+ return [], str(health_path)
644
+
645
+ if not isinstance(payload, dict):
646
+ return [], str(health_path)
647
+ status = str(payload.get("status") or "").strip().lower()
648
+ if not status or status == "ok":
649
+ return [], str(health_path)
650
+
651
+ date_value = str(payload.get("date") or "").strip()
652
+ error = _safe_packet_text(payload.get("error") or "unknown", max_chars=180)
653
+ lines = [f"status={status} date={date_value or '?'} error={error}"]
654
+
655
+ coverage = payload.get("coverage") or {}
656
+ if isinstance(coverage, dict):
657
+ visible = coverage.get("learnings_visible_count")
658
+ total = coverage.get("learnings_total_declared")
659
+ pct = coverage.get("coverage_pct")
660
+ if visible is not None or total is not None or pct is not None:
661
+ lines.append(f"coverage={visible or 0}/{total or 0} ({pct or 0}%)")
662
+
663
+ return lines, str(health_path)
664
+
665
+
666
+ def _latest_deep_sleep_synthesis() -> Path | None:
667
+ deep_sleep_dir = paths.operations_dir() / "deep-sleep"
668
+ try:
669
+ candidates = [
670
+ path
671
+ for path in deep_sleep_dir.glob("????-??-??-synthesis.json")
672
+ if path.is_file()
673
+ ]
674
+ except OSError:
675
+ return None
676
+ if not candidates:
677
+ return None
678
+ return max(candidates, key=lambda path: (path.name[:10], path.stat().st_mtime))
679
+
680
+
681
+ def _latest_deep_sleep_start_packet() -> Path | None:
682
+ deep_sleep_dir = paths.operations_dir() / "deep-sleep"
683
+ try:
684
+ candidates = [
685
+ path
686
+ for path in deep_sleep_dir.glob("????-??-??-agent-start-packet.json")
687
+ if path.is_file()
688
+ ]
689
+ except OSError:
690
+ return None
691
+ if not candidates:
692
+ return None
693
+ return max(candidates, key=lambda path: (path.name[:10], path.stat().st_mtime))
694
+
695
+
696
+ def _read_agent_start_packet(payload: dict, packet_path: Path) -> tuple[list[str], str]:
697
+ lines: list[str] = []
698
+ date_value = str(payload.get("date") or packet_path.name[:10]).strip()
699
+ summary = str(payload.get("summary") or "").strip()
700
+ if date_value:
701
+ lines.append(f"date={date_value}")
702
+ if summary:
703
+ lines.append(f"summary={_safe_packet_text(summary, max_chars=220)}")
704
+
705
+ agenda = payload.get("agenda") or []
706
+ if isinstance(agenda, list):
707
+ for item in agenda[:3]:
708
+ if not isinstance(item, dict):
709
+ continue
710
+ priority = str(item.get("priority") or "?").strip()
711
+ title = str(item.get("title") or "").strip()
712
+ description = str(item.get("description") or "").strip()
713
+ if title or description:
714
+ text = title if not description else f"{title} - {description}"
715
+ lines.append(f"agenda[{priority}]={_safe_packet_text(text, max_chars=220)}")
716
+
717
+ packets = payload.get("context_packets") or []
718
+ if isinstance(packets, list):
719
+ for packet in packets[:2]:
720
+ if not isinstance(packet, dict):
721
+ continue
722
+ topic = str(packet.get("topic") or "context").strip()
723
+ last_state = str(packet.get("last_state") or "").strip()
724
+ if topic or last_state:
725
+ text = topic if not last_state else f"{topic}: {last_state}"
726
+ lines.append(f"context={_safe_packet_text(text, max_chars=240)}")
727
+ files = packet.get("key_files") or []
728
+ if isinstance(files, list) and files:
729
+ rendered_files = ", ".join(str(file) for file in files[:4] if str(file).strip())
730
+ if rendered_files:
731
+ lines.append(f"files={_safe_packet_text(rendered_files, max_chars=220)}")
732
+
733
+ review_items = payload.get("review_items") or []
734
+ if isinstance(review_items, list):
735
+ for item in review_items[:2]:
736
+ if not isinstance(item, dict):
737
+ continue
738
+ title = str(item.get("title") or "").strip()
739
+ if title:
740
+ lines.append(f"review={_safe_packet_text(title, max_chars=220)}")
741
+
742
+ return lines[:10], str(packet_path)
743
+
744
+
745
+ def _read_deep_sleep_start_context() -> tuple[list[str], str]:
746
+ """Summarize the latest Deep Sleep synthesis for new agent sessions."""
747
+ packet_path = _latest_deep_sleep_start_packet()
748
+ if packet_path is not None:
749
+ try:
750
+ packet_payload = json.loads(packet_path.read_text(encoding="utf-8"))
751
+ except (OSError, json.JSONDecodeError):
752
+ packet_payload = None
753
+ if isinstance(packet_payload, dict):
754
+ return _read_agent_start_packet(packet_payload, packet_path)
755
+
756
+ synthesis_path = _latest_deep_sleep_synthesis()
757
+ if synthesis_path is None:
758
+ return [], str(paths.operations_dir() / "deep-sleep")
759
+
760
+ try:
761
+ payload = json.loads(synthesis_path.read_text(encoding="utf-8"))
762
+ except (OSError, json.JSONDecodeError):
763
+ return [], str(synthesis_path)
764
+ if not isinstance(payload, dict):
765
+ return [], str(synthesis_path)
766
+
767
+ lines: list[str] = []
768
+ date_value = str(payload.get("date") or synthesis_path.name[:10]).strip()
769
+ summary = str(payload.get("summary") or "").strip()
770
+ if date_value:
771
+ lines.append(f"date={date_value}")
772
+ if summary:
773
+ lines.append(f"summary={_safe_packet_text(summary, max_chars=220)}")
774
+
775
+ agenda = payload.get("morning_agenda") or []
776
+ if isinstance(agenda, list):
777
+ for item in agenda[:3]:
778
+ if not isinstance(item, dict):
779
+ continue
780
+ priority = str(item.get("priority") or "?").strip()
781
+ title = str(item.get("title") or "").strip()
782
+ description = str(item.get("description") or "").strip()
783
+ if title or description:
784
+ text = title if not description else f"{title} - {description}"
785
+ lines.append(f"agenda[{priority}]={_safe_packet_text(text, max_chars=220)}")
786
+
787
+ packets = payload.get("context_packets") or []
788
+ if isinstance(packets, list):
789
+ for packet in packets[:2]:
790
+ if not isinstance(packet, dict):
791
+ continue
792
+ topic = str(packet.get("topic") or "context").strip()
793
+ last_state = str(packet.get("last_state") or "").strip()
794
+ if topic or last_state:
795
+ text = topic if not last_state else f"{topic}: {last_state}"
796
+ lines.append(f"context={_safe_packet_text(text, max_chars=240)}")
797
+ files = packet.get("key_files") or []
798
+ if isinstance(files, list) and files:
799
+ rendered_files = ", ".join(str(file) for file in files[:4] if str(file).strip())
800
+ if rendered_files:
801
+ lines.append(f"files={_safe_packet_text(rendered_files, max_chars=220)}")
802
+
803
+ actions = payload.get("actions") or []
804
+ if isinstance(actions, list):
805
+ review_count = 0
806
+ for action in actions:
807
+ if review_count >= 2 or not isinstance(action, dict):
808
+ continue
809
+ if action.get("action_class") != "draft_for_morning":
810
+ continue
811
+ content = action.get("content") or {}
812
+ if isinstance(content, dict):
813
+ title = str(content.get("title") or content.get("description") or "").strip()
814
+ else:
815
+ title = str(content or "").strip()
816
+ if title:
817
+ lines.append(f"review={_safe_packet_text(title, max_chars=220)}")
818
+ review_count += 1
819
+
820
+ return lines[:10], str(synthesis_path)
821
+
822
+
637
823
  def handle_startup(
638
824
  task: str = "Startup",
639
825
  claude_session_id: str = "",
@@ -796,6 +982,22 @@ def handle_startup(
796
982
  lines.append(f" {_safe_packet_text(raw_line)}")
797
983
  lines.append(f" Full briefing: {_safe_packet_text(briefing_path)}")
798
984
 
985
+ sleep_health, sleep_health_path = _read_sleep_health_warning()
986
+ if sleep_health:
987
+ lines.append("")
988
+ lines.append("SLEEP HEALTH:")
989
+ for raw_line in sleep_health:
990
+ lines.append(f" {_safe_packet_text(raw_line)}")
991
+ lines.append(f" Full health: {_safe_packet_text(sleep_health_path)}")
992
+
993
+ start_context, start_context_path = _read_deep_sleep_start_context()
994
+ if start_context:
995
+ lines.append("")
996
+ lines.append("DEEP SLEEP CONTEXT:")
997
+ for raw_line in start_context:
998
+ lines.append(f" {_safe_packet_text(raw_line)}")
999
+ lines.append(f" Full synthesis: {_safe_packet_text(start_context_path)}")
1000
+
799
1001
  try:
800
1002
  from memory_layer_audit import audit_memory_layers, format_memory_layer_warnings
801
1003
 
@@ -4,6 +4,13 @@ Write the email using ONLY the facts present in the structured context below.
4
4
  Use the operator's preferred language: [[operator_language]].
5
5
  If the language value is invalid or unclear, use English.
6
6
 
7
+ Product intent:
8
+ - This is a start-of-day briefing, not a report dump. The operator should finish reading it knowing what matters, what changed, what can wait, and what needs a decision.
9
+ - Think like a professional personal assistant or chief of staff: filter noise, rank priorities, connect related items, and make the day feel prepared.
10
+ - Adapt the emphasis to the operator's real context: administrative work needs tasks, deadlines, email movement and missing inputs; executives need decisions, risks, money, people and blocked outcomes; technical users need incidents, deployments, regressions and dependencies; commercial users need leads, customers and follow-ups; regulated or clinical users need careful wording, pending actions and factual status only.
11
+ - Do not ask the operator to choose a user type. Infer the useful angle from the structured context, profile fields, recent activity and explicit preferences. If the context is thin, stay general and practical.
12
+ - Include news and weather only when verified collected data exists in the context. Never fabricate public news, forecasts, calendar events, emails or professional advice.
13
+
7
14
  Hard rules:
8
15
  - Do not invent achievements, blockers, meetings, messages, or external events.
9
16
  - Do not mention source files, JSON, MCP, prompts, or internal implementation.