nexo-brain 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/scripts/migrate-v1.7-to-v1.8.py +2 -2
  4. package/scripts/nexo-preflight.sh +236 -0
  5. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  6. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  7. package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
  8. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  9. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  10. package/src/auto_update.py +25 -0
  11. package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  13. package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
  14. package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
  15. package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
  16. package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
  17. package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
  18. package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
  19. package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
  20. package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
  21. package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
  22. package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
  23. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  24. package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
  25. package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
  26. package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
  27. package/src/crons/manifest.json +6 -13
  28. package/src/crons/sync.py +151 -6
  29. package/src/db/__init__.py +13 -0
  30. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
  34. package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
  35. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  36. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  37. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  38. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  39. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  40. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  41. package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
  42. package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
  43. package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
  44. package/src/db/_cron_runs.py +74 -0
  45. package/src/db/_episodic.py +40 -6
  46. package/src/db/_schema.py +64 -0
  47. package/src/db/_skills.py +514 -0
  48. package/src/hooks/session-stop.sh +13 -101
  49. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  51. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  52. package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
  53. package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
  54. package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
  55. package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
  56. package/src/plugins/episodic_memory.py +5 -3
  57. package/src/plugins/schedule.py +212 -0
  58. package/src/plugins/skills.py +264 -0
  59. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  60. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  61. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  62. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  63. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  64. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  65. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  66. package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
  67. package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
  68. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  69. package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
  70. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  71. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  72. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  73. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  74. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  75. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  76. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  77. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  78. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  79. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  80. package/src/scripts/deep-sleep/apply_findings.py +110 -8
  81. package/src/scripts/deep-sleep/collect.py +33 -11
  82. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  83. package/src/scripts/deep-sleep/extract.py +80 -8
  84. package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
  85. package/src/scripts/deep-sleep/synthesize.py +3 -1
  86. package/src/scripts/nexo-catchup.py +65 -29
  87. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  88. package/src/scripts/nexo-daily-self-audit.py +4 -2
  89. package/src/scripts/nexo-deep-sleep.sh +66 -77
  90. package/src/scripts/nexo-evolution-run.py +13 -0
  91. package/src/scripts/nexo-learning-housekeep.py +156 -1
  92. package/src/scripts/nexo-learning-validator.py +19 -0
  93. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  94. package/src/scripts/nexo-sleep.py +16 -11
  95. package/src/scripts/nexo-synthesis.py +46 -3
  96. package/src/scripts/nexo-watchdog.sh +72 -19
  97. package/src/server.py +5 -1
  98. package/src/scripts/nexo-github-monitor.py +0 -256
@@ -0,0 +1,53 @@
1
+ #!/bin/bash
2
+ # NEXO Cron Wrapper — Records execution in cron_runs table.
3
+ # Usage: nexo-cron-wrapper.sh <cron_id> <command...>
4
+ # Example: nexo-cron-wrapper.sh deep-sleep bash nexo-deep-sleep.sh
5
+ #
6
+ # Wraps any cron command to automatically record start/end/exit_code/summary.
7
+ # Used by sync.py when generating LaunchAgents from manifest.json.
8
+
9
+ set -uo pipefail
10
+
11
+ CRON_ID="${1:?Usage: nexo-cron-wrapper.sh <cron_id> <command...>}"
12
+ shift
13
+
14
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
15
+ DB="$NEXO_HOME/data/nexo.db"
16
+
17
+ # Record start
18
+ RUN_ID=$(sqlite3 "$DB" "INSERT INTO cron_runs (cron_id) VALUES ('$CRON_ID'); SELECT last_insert_rowid();" 2>/dev/null)
19
+
20
+ if [ -z "$RUN_ID" ]; then
21
+ # DB not ready — run without tracking
22
+ exec "$@"
23
+ fi
24
+
25
+ # Run the actual command, capture output
26
+ OUTPUT_FILE=$(mktemp)
27
+ "$@" > "$OUTPUT_FILE" 2>&1
28
+ EXIT_CODE=$?
29
+
30
+ # Extract summary (last meaningful line, max 500 chars)
31
+ SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500 | sed "s/'/''/g")
32
+
33
+ # Extract error if failed
34
+ ERROR=""
35
+ if [ $EXIT_CODE -ne 0 ]; then
36
+ ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500 | sed "s/'/''/g")
37
+ fi
38
+
39
+ # Record end
40
+ sqlite3 "$DB" "
41
+ UPDATE cron_runs SET
42
+ ended_at = datetime('now'),
43
+ exit_code = $EXIT_CODE,
44
+ summary = '$SUMMARY',
45
+ error = '$ERROR',
46
+ duration_secs = ROUND((julianday(datetime('now')) - julianday(started_at)) * 86400, 1)
47
+ WHERE id = $RUN_ID;
48
+ " 2>/dev/null
49
+
50
+ # Clean output
51
+ rm -f "$OUTPUT_FILE"
52
+
53
+ exit $EXIT_CODE
@@ -532,8 +532,10 @@ def main():
532
532
  "counts": {"error": errors, "warn": warns, "info": infos}
533
533
  }, indent=2))
534
534
 
535
- # Stage B: CLI interpretation
536
- interpret_findings(findings)
535
+ # Stage B: CLI interpretation (graceful fallback if CLI unavailable)
536
+ cli_ok = interpret_findings(findings)
537
+ if not cli_ok:
538
+ log("Stage B: CLI unavailable or failed. Stage A results saved to self-audit-summary.json.")
537
539
 
538
540
  # Register for catch-up
539
541
  try:
@@ -1,13 +1,11 @@
1
1
  #!/bin/bash
2
- # NEXO Deep Sleep — Complete overnight session analysis
2
+ # NEXO Deep Sleep — Overnight session analysis with watermark tracking
3
3
  # Runs at 4:30 AM via LaunchAgent
4
- # Reads ALL session transcripts from the day, analyzes with Claude CLI,
5
- # and applies findings (learnings, feedbacks, followups, trust adjustments)
6
4
  #
7
- # Features:
8
- # - Catch-up: if yesterday was missed (Mac off/asleep), runs it first
9
- # - Logs to $NEXO_HOME/logs/deep-sleep.log
10
- # - Marks completion in .last-run for watchdog monitoring
5
+ # Watermark approach: tracks the last processed timestamp so nothing is missed.
6
+ # Sessions from late-night/early-morning work are included in the next run.
7
+ #
8
+ # Logs to $NEXO_HOME/logs/deep-sleep.log
11
9
 
12
10
  set -euo pipefail
13
11
 
@@ -15,83 +13,74 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
13
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
16
14
  LOG_DIR="$NEXO_HOME/logs"
17
15
  DEEP_SLEEP_DIR="$NEXO_HOME/operations/deep-sleep"
18
- LAST_RUN_FILE="$DEEP_SLEEP_DIR/.last-run"
19
- TODAY=$(date +%Y-%m-%d)
16
+ WATERMARK_FILE="$DEEP_SLEEP_DIR/.watermark"
17
+ RUN_ID=$(date +%Y-%m-%d)
20
18
 
21
19
  mkdir -p "$LOG_DIR" "$DEEP_SLEEP_DIR"
22
20
 
23
21
  log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_DIR/deep-sleep.log"; }
24
22
 
25
- run_analysis() {
26
- local DATE="$1"
27
- log "=== Deep Sleep v2 starting for $DATE ==="
28
-
29
- # Phase 1: Collect all context (Python, no LLM)
30
- log "Phase 1: Collecting context for $DATE..."
31
- python3 "$SCRIPT_DIR/deep-sleep/collect.py" "$DATE" 2>&1 | tee -a "$LOG_DIR/deep-sleep.log"
32
-
33
- if [ ! -f "$DEEP_SLEEP_DIR/$DATE-context.txt" ]; then
34
- log "No context file generated for $DATE. Skipping."
35
- return 0
36
- fi
37
-
38
- # Check meta for session count
39
- SESSIONS=0
40
- if [ -f "$DEEP_SLEEP_DIR/$DATE-meta.json" ]; then
41
- SESSIONS=$(python3 -c "import json; print(json.load(open('$DEEP_SLEEP_DIR/$DATE-meta.json'))['sessions_found'])")
42
- elif [ -f "$DEEP_SLEEP_DIR/$DATE-index.json" ]; then
43
- SESSIONS=$(python3 -c "import json; print(json.load(open('$DEEP_SLEEP_DIR/$DATE-index.json'))['sessions_found'])")
44
- fi
45
- if [ "$SESSIONS" -eq 0 ]; then
46
- log "No sessions found for $DATE. Skipping."
47
- return 0
48
- fi
49
-
50
- # Phase 2: Extract findings per session (Claude Opus)
51
- log "Phase 2: Extracting findings from $SESSIONS sessions..."
52
- python3 "$SCRIPT_DIR/deep-sleep/extract.py" "$DATE" 2>&1 | tee -a "$LOG_DIR/deep-sleep.log"
53
-
54
- if [ ! -f "$DEEP_SLEEP_DIR/$DATE-extractions.json" ]; then
55
- log "Extraction failed for $DATE. No output."
56
- return 1
57
- fi
58
-
59
- # Phase 3: Cross-session synthesis (Claude Opus, one call)
60
- log "Phase 3: Synthesizing cross-session findings..."
61
- python3 "$SCRIPT_DIR/deep-sleep/synthesize.py" "$DATE" 2>&1 | tee -a "$LOG_DIR/deep-sleep.log"
62
-
63
- if [ ! -f "$DEEP_SLEEP_DIR/$DATE-synthesis.json" ]; then
64
- log "Synthesis failed for $DATE. Falling back to extractions only."
65
- # Fall back: apply extractions directly
66
- cp "$DEEP_SLEEP_DIR/$DATE-extractions.json" "$DEEP_SLEEP_DIR/$DATE-synthesis.json"
67
- fi
68
-
69
- # Phase 4: Apply findings
70
- log "Phase 4: Applying findings..."
71
- python3 "$SCRIPT_DIR/deep-sleep/apply_findings.py" "$DATE" 2>&1 | tee -a "$LOG_DIR/deep-sleep.log"
72
-
73
- log "=== Deep Sleep v2 complete for $DATE ==="
74
- return 0
75
- }
76
-
77
- # --- Catch-up: check if the day before yesterday was missed ---
78
- YESTERDAY=$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "yesterday" +%Y-%m-%d 2>/dev/null)
79
- DAY_BEFORE=$(date -v-2d +%Y-%m-%d 2>/dev/null || date -d "2 days ago" +%Y-%m-%d 2>/dev/null)
80
- LAST_RUN=""
81
- if [ -f "$LAST_RUN_FILE" ]; then
82
- LAST_RUN=$(cat "$LAST_RUN_FILE")
23
+ # Read watermark (last processed timestamp)
24
+ SINCE=""
25
+ if [ -f "$WATERMARK_FILE" ]; then
26
+ SINCE=$(cat "$WATERMARK_FILE")
27
+ log "Watermark: processing sessions since $SINCE"
28
+ else
29
+ # First run ever: process last 48h
30
+ SINCE=$(date -v-2d '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || date -d "2 days ago" '+%Y-%m-%dT%H:%M:%S' 2>/dev/null)
31
+ log "No watermark found. First run, collecting since $SINCE"
83
32
  fi
84
33
 
85
- if [ -n "$DAY_BEFORE" ] && [ "$LAST_RUN" != "$DAY_BEFORE" ] && [ "$LAST_RUN" != "$YESTERDAY" ]; then
86
- # Day before yesterday wasn't analyzed — catch up
87
- if [ ! -f "$DEEP_SLEEP_DIR/$DAY_BEFORE-analysis.json" ]; then
88
- log "*** CATCH-UP: $DAY_BEFORE was missed. Running now. ***"
89
- run_analysis "$DAY_BEFORE" || log "Catch-up for $DAY_BEFORE failed."
90
- fi
34
+ UNTIL=$(date '+%Y-%m-%dT%H:%M:%S')
35
+
36
+ log "=== Deep Sleep v2 starting (run_id=$RUN_ID) ==="
37
+
38
+ # Phase 1: Collect all context (Python, no LLM)
39
+ log "Phase 1: Collecting context since $SINCE until $UNTIL..."
40
+ python3 "$SCRIPT_DIR/deep-sleep/collect.py" "$RUN_ID" "$SINCE" "$UNTIL" >> "$LOG_DIR/deep-sleep.log" 2>&1
41
+
42
+ if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-context.txt" ]; then
43
+ log "No context file generated. Skipping."
44
+ echo "$UNTIL" > "$WATERMARK_FILE"
45
+ log "Watermark updated to $UNTIL (no sessions to process)"
46
+ exit 0
47
+ fi
48
+
49
+ # Check meta for session count
50
+ SESSIONS=0
51
+ if [ -f "$DEEP_SLEEP_DIR/$RUN_ID-meta.json" ]; then
52
+ SESSIONS=$(python3 -c "import json; print(json.load(open('$DEEP_SLEEP_DIR/$RUN_ID-meta.json'))['sessions_found'])")
53
+ fi
54
+ if [ "$SESSIONS" -eq 0 ]; then
55
+ log "No sessions found. Skipping."
56
+ echo "$UNTIL" > "$WATERMARK_FILE"
57
+ log "Watermark updated to $UNTIL (no sessions)"
58
+ exit 0
59
+ fi
60
+
61
+ # Phase 2: Extract findings per session (Claude Opus)
62
+ log "Phase 2: Extracting findings from $SESSIONS sessions..."
63
+ python3 "$SCRIPT_DIR/deep-sleep/extract.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
64
+
65
+ if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-extractions.json" ]; then
66
+ log "Extraction failed. Watermark NOT updated (will retry next run)."
67
+ exit 1
68
+ fi
69
+
70
+ # Phase 3: Cross-session synthesis (Claude Opus, one call)
71
+ log "Phase 3: Synthesizing cross-session findings..."
72
+ python3 "$SCRIPT_DIR/deep-sleep/synthesize.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
73
+
74
+ if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-synthesis.json" ]; then
75
+ log "Synthesis failed. Falling back to extractions only."
76
+ cp "$DEEP_SLEEP_DIR/$RUN_ID-extractions.json" "$DEEP_SLEEP_DIR/$RUN_ID-synthesis.json"
91
77
  fi
92
78
 
93
- # --- Run yesterday's analysis (main task — at 4:30 AM, today has no sessions yet) ---
94
- run_analysis "$YESTERDAY"
79
+ # Phase 4: Apply findings
80
+ log "Phase 4: Applying findings..."
81
+ python3 "$SCRIPT_DIR/deep-sleep/apply_findings.py" "$RUN_ID" >> "$LOG_DIR/deep-sleep.log" 2>&1
95
82
 
96
- # Mark completion with yesterday's date (what we actually analyzed)
97
- echo "$YESTERDAY" > "$LAST_RUN_FILE"
83
+ # Update watermark on success
84
+ echo "$UNTIL" > "$WATERMARK_FILE"
85
+ log "Watermark updated to $UNTIL"
86
+ log "=== Deep Sleep v2 complete (run_id=$RUN_ID) ==="
@@ -104,6 +104,19 @@ CLI_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
104
104
 
105
105
 
106
106
  def verify_claude_cli() -> bool:
107
+ """Check Claude CLI is available and authenticated."""
108
+ if not CLAUDE_CLI.exists():
109
+ return False
110
+ try:
111
+ result = subprocess.run(
112
+ [str(CLAUDE_CLI), "-p", "reply OK", "--output-format", "text"],
113
+ capture_output=True, text=True, timeout=30
114
+ )
115
+ return result.returncode == 0
116
+ except Exception:
117
+ return False
118
+
119
+
107
120
  def call_claude_cli(prompt: str) -> str:
108
121
  """Call claude -p prompt --model opus via subprocess. Returns stdout text."""
109
122
  env = os.environ.copy()
@@ -31,6 +31,7 @@ MIN_WEIGHT = 0.05
31
31
  MAX_WEIGHT = 1.0
32
32
  DEDUP_THRESHOLD = 0.85 # cosine similarity for duplicate detection
33
33
  ARCHIVE_AFTER_DAYS = 90 # archive if weight < 0.1 and no hits in this many days
34
+ REVIEW_EXTEND_DAYS = 30 # extend review_due by this many days when confirming
34
35
 
35
36
 
36
37
  def get_db():
@@ -195,6 +196,157 @@ def archive_stale(conn):
195
196
  return len(stale)
196
197
 
197
198
 
199
+ def _reconcile_decision_outcome(conn, decision_id: int, decision_text: str) -> str | None:
200
+ """Try to find evidence of a decision's outcome in diaries, followups, and change_log.
201
+
202
+ Returns outcome text if found, None otherwise.
203
+ """
204
+ # Extract keywords from the decision for matching
205
+ keywords = [w for w in decision_text.lower().split() if len(w) > 4][:5]
206
+ if not keywords:
207
+ return None
208
+
209
+ like_clauses = " OR ".join(f"summary LIKE ?" for _ in keywords)
210
+ like_params = [f"%{kw}%" for kw in keywords]
211
+
212
+ # Check session diaries for evidence
213
+ diary_match = conn.execute(
214
+ f"SELECT summary FROM session_diary WHERE ({like_clauses}) "
215
+ "AND created_at > (SELECT created_at FROM decisions WHERE id = ?) "
216
+ "ORDER BY created_at DESC LIMIT 1",
217
+ like_params + [decision_id]
218
+ ).fetchone()
219
+ if diary_match:
220
+ return f"[auto-reconciled from diary] {diary_match['summary'][:200]}"
221
+
222
+ # Check completed followups
223
+ like_clauses_f = " OR ".join(f"description LIKE ?" for _ in keywords)
224
+ followup_match = conn.execute(
225
+ f"SELECT description, verification FROM followups WHERE status = 'COMPLETED' "
226
+ f"AND ({like_clauses_f}) ORDER BY date DESC LIMIT 1",
227
+ like_params
228
+ ).fetchone()
229
+ if followup_match:
230
+ result = followup_match['verification'] or followup_match['description']
231
+ return f"[auto-reconciled from followup] {result[:200]}"
232
+
233
+ # Check change_log (schema: what_changed, why, commit_ref, affects)
234
+ like_clauses_c = " OR ".join(f"what_changed LIKE ?" for _ in keywords)
235
+ change_match = conn.execute(
236
+ f"SELECT what_changed, why, commit_ref FROM change_log WHERE ({like_clauses_c}) "
237
+ "ORDER BY created_at DESC LIMIT 1",
238
+ like_params
239
+ ).fetchone()
240
+ if change_match:
241
+ ref = change_match['commit_ref'] or ''
242
+ desc = change_match['what_changed'] or change_match['why'] or ''
243
+ return f"[auto-reconciled from change_log] {desc[:150]} {ref}"
244
+
245
+ return None
246
+
247
+
248
+ def process_overdue_reviews(conn):
249
+ """Process learnings and decisions whose review_due_at has passed.
250
+
251
+ Learnings:
252
+ - guard_hits > 5 since last review -> confirm (extend review_due by 30 days)
253
+ - guard_hits = 0 and weight < 0.3 -> archive
254
+ - otherwise -> extend review_due by 30 days (still useful, just not urgent)
255
+
256
+ Decisions:
257
+ - status = 'pending_review' and review_due_at < now -> archive if >30 days old
258
+ """
259
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
260
+ now = time.time()
261
+ now_iso = datetime.now().isoformat(timespec="seconds")
262
+
263
+ # --- Overdue learnings ---
264
+ try:
265
+ overdue_learnings = conn.execute(
266
+ "SELECT id, title, weight, guard_hits, review_due_at, last_reviewed_at "
267
+ "FROM learnings "
268
+ "WHERE review_due_at IS NOT NULL AND review_due_at <= ? AND status = 'active'",
269
+ (now,)
270
+ ).fetchall()
271
+ except Exception as e:
272
+ print(f"[{ts}] Overdue reviews: error querying learnings: {e}")
273
+ return 0
274
+
275
+ confirmed = 0
276
+ archived = 0
277
+ for l in overdue_learnings:
278
+ lid = l["id"]
279
+ hits = l["guard_hits"] or 0
280
+ weight = l["weight"] or 0.5
281
+ last_reviewed = l["last_reviewed_at"] or 0
282
+
283
+ if hits > 5:
284
+ # Active and useful -- confirm: extend review date
285
+ new_due = now + (REVIEW_EXTEND_DAYS * 86400)
286
+ conn.execute(
287
+ "UPDATE learnings SET review_due_at = ?, last_reviewed_at = ? WHERE id = ?",
288
+ (new_due, now, lid)
289
+ )
290
+ confirmed += 1
291
+ elif hits == 0 and weight < 0.3:
292
+ # Unused and low weight -- archive
293
+ conn.execute(
294
+ "UPDATE learnings SET status = 'archived' WHERE id = ?",
295
+ (lid,)
296
+ )
297
+ archived += 1
298
+ print(f"[{ts}] Archived overdue learning #{lid} '{l['title'][:50]}' (hits=0, weight={weight:.2f})")
299
+ else:
300
+ # Middle ground -- extend review date, keep active
301
+ new_due = now + (REVIEW_EXTEND_DAYS * 86400)
302
+ conn.execute(
303
+ "UPDATE learnings SET review_due_at = ?, last_reviewed_at = ? WHERE id = ?",
304
+ (new_due, now, lid)
305
+ )
306
+ confirmed += 1
307
+
308
+ # --- Overdue decisions ---
309
+ decision_archived = 0
310
+ try:
311
+ cutoff_30d = (datetime.now() - timedelta(days=30)).isoformat(timespec="seconds")
312
+ overdue_decisions = conn.execute(
313
+ "SELECT id, decision, created_at FROM decisions "
314
+ "WHERE status = 'pending_review' AND review_due_at IS NOT NULL AND review_due_at <= ?",
315
+ (now_iso,)
316
+ ).fetchall()
317
+
318
+ for d in overdue_decisions:
319
+ did = d["id"]
320
+ created = d["created_at"] or ""
321
+ decision_text = d["decision"] or ""
322
+
323
+ # Try to reconcile outcome from diaries, followups, change_log
324
+ outcome = _reconcile_decision_outcome(conn, did, decision_text)
325
+ if outcome:
326
+ conn.execute(
327
+ "UPDATE decisions SET status = 'resolved', outcome = ? WHERE id = ?",
328
+ (outcome, did)
329
+ )
330
+ decision_archived += 1
331
+ print(f"[{ts}] Resolved decision #{did} '{decision_text[:50]}' — outcome found in logs")
332
+ elif created < cutoff_30d:
333
+ conn.execute(
334
+ "UPDATE decisions SET status = 'archived' WHERE id = ?",
335
+ (did,)
336
+ )
337
+ decision_archived += 1
338
+ print(f"[{ts}] Archived decision #{did} '{decision_text[:50]}' (>30d, no outcome found)")
339
+ except Exception as e:
340
+ print(f"[{ts}] Overdue reviews: error processing decisions: {e}")
341
+
342
+ conn.commit()
343
+ total_learnings = len(overdue_learnings) if 'overdue_learnings' in dir() else 0
344
+ total_decisions = len(overdue_decisions) if 'overdue_decisions' in dir() else 0
345
+ print(f"[{ts}] Overdue reviews: {total_learnings} learnings ({confirmed} confirmed, {archived} archived), "
346
+ f"{total_decisions} decisions ({decision_archived} archived)")
347
+ return confirmed + archived + decision_archived
348
+
349
+
198
350
  def print_summary(conn):
199
351
  """Print summary stats."""
200
352
  ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -233,7 +385,10 @@ def main():
233
385
  # 4. Archive stale learnings
234
386
  archive_stale(conn)
235
387
 
236
- # 5. Summary
388
+ # 5. Process overdue reviews (review_due_at < now)
389
+ process_overdue_reviews(conn)
390
+
391
+ # 6. Summary
237
392
  print_summary(conn)
238
393
 
239
394
  conn.close()
@@ -111,6 +111,25 @@ Rules:
111
111
 
112
112
  # Try CLI first, fall back to mechanical similarity
113
113
  if CLAUDE_CLI.exists():
114
+ try:
115
+ env = os.environ.copy()
116
+ env["NEXO_HEADLESS"] = "1"
117
+ env.pop("CLAUDECODE", None)
118
+ env.pop("CLAUDE_CODE", None)
119
+ learnings_text = "\n".join(
120
+ f"[#{l.get('id','')}] {l.get('title','')}: {l.get('content','')[:200]}"
121
+ for l in learnings[:20]
122
+ )
123
+ prompt = f"{VALIDATE_PROMPT}\n\nFinding:\n{finding}\n\nExisting learnings:\n{learnings_text}"
124
+ result = subprocess.run(
125
+ [str(CLAUDE_CLI), "-p", prompt, "--model", "sonnet", "--output-format", "text"],
126
+ capture_output=True, text=True, timeout=60, env=env
127
+ )
128
+ if result.returncode == 0 and result.stdout.strip():
129
+ parsed = json.loads(result.stdout.strip())
130
+ return parsed
131
+ except Exception:
132
+ pass
114
133
  # Fallback: mechanical SequenceMatcher (original logic)
115
134
  return _mechanical_validate(finding, learnings)
116
135
 
@@ -387,10 +387,11 @@ def main():
387
387
  if not data["diaries"]:
388
388
  log("No session diaries today. Nothing to consolidate.")
389
389
  else:
390
- # Stage 2: CLI intelligence
390
+ # Stage 2: CLI intelligence (graceful fallback: Stage 3 still runs)
391
391
  success = consolidate_with_cli(data)
392
392
  if not success:
393
- log("Stage 2 failed falling back to skip (no v1 fallback)")
393
+ log("Stage 2 failed (CLI unavailable or error). "
394
+ "Skipping intelligent consolidation. Stage 3 (sensory + force) will still run.")
394
395
 
395
396
  # Stage 3: Sensory Register (mechanical, kept from v1)
396
397
  try:
@@ -550,18 +550,23 @@ def main():
550
550
  log(f"Brain state: {len(state['learnings'])} learnings, "
551
551
  f"{state['memory_md_lines']} MEMORY lines, "
552
552
  f"{state['claude_mem_old']} old observations")
553
- run_log["stage_b"] = dream(state)
554
-
555
- # Stage B2: Execute actions from CLI output
556
- actions_file = COORD_DIR / "sleep-actions.json"
557
- if actions_file.exists():
558
- try:
559
- actions = json.loads(actions_file.read_text())
560
- execute_dream_actions(actions, state)
561
- except Exception as e:
562
- log(f"Stage B2: Error executing actions: {e}")
553
+ dream_result = dream(state)
554
+ run_log["stage_b"] = dream_result
555
+
556
+ if "error" in dream_result:
557
+ log(f"Stage B: Dreaming failed ({dream_result['error']}). "
558
+ "Stage A cleanup completed successfully. Marking done to avoid retry loop.")
559
+ else:
560
+ # Stage B2: Execute actions from CLI output
561
+ actions_file = COORD_DIR / "sleep-actions.json"
562
+ if actions_file.exists():
563
+ try:
564
+ actions = json.loads(actions_file.read_text())
565
+ execute_dream_actions(actions, state)
566
+ except Exception as e:
567
+ log(f"Stage B2: Error executing actions: {e}")
563
568
  else:
564
- log("Brain is clean no dreaming needed.")
569
+ log("Brain is clean -- no dreaming needed.")
565
570
  run_log["stage_b"] = {"skipped": True}
566
571
 
567
572
  # Done
@@ -6,7 +6,7 @@ Before: ~400 lines of Python concatenating SQL results into markdown sections.
6
6
  Now: Collects raw data, passes to Claude CLI (sonnet) which synthesizes
7
7
  with real understanding of what matters for tomorrow.
8
8
 
9
- Runs every 2 hours via LaunchAgent. Executes ONCE per day (internal gate).
9
+ Runs daily at 06:00 via LaunchAgent.
10
10
  """
11
11
 
12
12
  import fcntl
@@ -213,6 +213,47 @@ Execute without asking."""
213
213
  return False
214
214
 
215
215
 
216
+ def fallback_synthesis(data: dict):
217
+ """Write a basic synthesis from raw data when CLI is unavailable."""
218
+ log("Fallback: writing basic synthesis from raw data...")
219
+ lines = [f"# NEXO Daily Synthesis -- {TODAY_STR}", "",
220
+ "*(Generated by fallback -- CLI was unavailable)*", ""]
221
+
222
+ if data.get("learnings"):
223
+ lines.append("## Errors & Learnings")
224
+ for l in data["learnings"][:10]:
225
+ lines.append(f"- [{l.get('category', 'general')}] {l.get('title', 'untitled')}")
226
+ lines.append("")
227
+
228
+ if data.get("decisions"):
229
+ lines.append("## Decisions Made")
230
+ for d in data["decisions"][:10]:
231
+ lines.append(f"- [{d.get('domain', 'general')}] {d.get('decision', '')[:120]}")
232
+ lines.append("")
233
+
234
+ if data.get("changes"):
235
+ lines.append("## Changes Deployed")
236
+ for c in data["changes"][:10]:
237
+ lines.append(f"- {c.get('what_changed', '')[:120]}")
238
+ lines.append("")
239
+
240
+ if data.get("overdue_reminders"):
241
+ lines.append("## Overdue Reminders")
242
+ for r in data["overdue_reminders"][:10]:
243
+ lines.append(f"- #{r.get('id', '?')} {r.get('title', '')} (due {r.get('due_date', '?')})")
244
+ lines.append("")
245
+
246
+ if data.get("pending_followups"):
247
+ lines.append("## Pending Followups")
248
+ for f in data["pending_followups"][:10]:
249
+ lines.append(f"- #{f.get('id', '?')} {f.get('title', '')} (due {f.get('due_date', '?')})")
250
+ lines.append("")
251
+
252
+ OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
253
+ OUTPUT_FILE.write_text("\n".join(lines))
254
+ log(f"Fallback synthesis written to {OUTPUT_FILE}")
255
+
256
+
216
257
  def main():
217
258
  if not should_run():
218
259
  log(f"Already ran today ({TODAY_STR}). Skipping.")
@@ -220,7 +261,7 @@ def main():
220
261
 
221
262
  lock_fd = acquire_lock()
222
263
  try:
223
- log(f"=== NEXO Synthesis v2 {TODAY_STR} ===")
264
+ log(f"=== NEXO Synthesis v2 -- {TODAY_STR} ===")
224
265
 
225
266
  data = collect_data()
226
267
  log(f"Collected: {len(data.get('learnings', []))} learnings, "
@@ -234,7 +275,9 @@ def main():
234
275
  mark_done()
235
276
  log("Synthesis v2 complete.")
236
277
  else:
237
- log("Synthesis failed will retry next trigger.")
278
+ log("Synthesis CLI failed -- writing fallback synthesis.")
279
+ fallback_synthesis(data)
280
+ mark_done()
238
281
 
239
282
  # Register for catch-up
240
283
  try: