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
@@ -135,21 +135,52 @@ def update_calibration_mood(synthesis: dict) -> dict:
135
135
  # Keep last 30 days
136
136
  cal["mood_history"] = cal["mood_history"][-30:]
137
137
 
138
- # Apply calibration recommendation if any
138
+ # Apply calibration recommendation automatically
139
139
  rec = emotional_day.get("calibration_recommendation")
140
140
  if rec and rec != "null":
141
- if "calibration_notes" not in cal:
142
- cal["calibration_notes"] = []
143
- cal["calibration_notes"].append({
141
+ applied_changes = []
142
+
143
+ # Parse and apply known calibration adjustments
144
+ rec_lower = rec.lower()
145
+ personality = cal.get("personality", {})
146
+
147
+ # Autonomy adjustments
148
+ if "autonomy" in rec_lower or "autonomía" in rec_lower:
149
+ if any(w in rec_lower for w in ["full", "más autonomía", "subir", "increase"]):
150
+ personality["autonomy"] = "full"
151
+ applied_changes.append("autonomy → full")
152
+ elif any(w in rec_lower for w in ["conservative", "reducir", "bajar"]):
153
+ personality["autonomy"] = "conservative"
154
+ applied_changes.append("autonomy → conservative")
155
+
156
+ # Communication adjustments
157
+ if any(w in rec_lower for w in ["concis", "breve", "shorter", "telegráf"]):
158
+ personality["communication"] = "concise"
159
+ applied_changes.append("communication → concise")
160
+ elif any(w in rec_lower for w in ["detail", "explicar más", "más contexto"]):
161
+ personality["communication"] = "detailed"
162
+ applied_changes.append("communication → detailed")
163
+
164
+ # Proactivity adjustments
165
+ if any(w in rec_lower for w in ["más proactiv", "proactive", "anticipar"]):
166
+ personality["proactivity"] = "proactive"
167
+ applied_changes.append("proactivity → proactive")
168
+
169
+ cal["personality"] = personality
170
+
171
+ # Log the recommendation and what was applied
172
+ if "calibration_log" not in cal:
173
+ cal["calibration_log"] = []
174
+ cal["calibration_log"].append({
144
175
  "date": synthesis.get("date", ""),
145
176
  "recommendation": rec,
146
- "applied": False,
177
+ "applied": applied_changes if applied_changes else ["noted, no auto-applicable changes"],
147
178
  })
148
- # Keep last 10
149
- cal["calibration_notes"] = cal["calibration_notes"][-10:]
179
+ cal["calibration_log"] = cal["calibration_log"][-20:]
150
180
 
151
181
  calibration_file.write_text(json.dumps(cal, indent=2, ensure_ascii=False))
152
- return {"success": True, "mood_score": emotional_day.get("mood_score")}
182
+ changes_str = ", ".join(applied_changes) if rec and applied_changes else "none"
183
+ return {"success": True, "mood_score": emotional_day.get("mood_score"), "calibration_applied": changes_str}
153
184
  except Exception as e:
154
185
  return {"success": False, "error": str(e)}
155
186
 
@@ -203,6 +234,52 @@ def calibrate_trust_score(synthesis: dict, target_date: str) -> dict:
203
234
  return {"success": False, "error": str(e)}
204
235
 
205
236
 
237
+ def create_skill(skill_data: dict) -> dict:
238
+ """Create a skill in nexo.db from Deep Sleep extraction."""
239
+ if not NEXO_DB.exists():
240
+ return {"success": False, "error": "nexo.db not found"}
241
+ try:
242
+ import hashlib
243
+ skill_id = skill_data.get("id", "")
244
+ if not skill_id:
245
+ skill_id = "SK-DS-" + hashlib.md5(
246
+ skill_data.get("name", "").encode()
247
+ ).hexdigest()[:8].upper()
248
+
249
+ name = skill_data.get("name", "")
250
+ description = skill_data.get("description", "")
251
+ tags = json.dumps(skill_data.get("tags", []))
252
+ trigger_patterns = json.dumps(skill_data.get("trigger_patterns", []))
253
+ source_sessions = json.dumps(skill_data.get("source_sessions", []))
254
+ steps = skill_data.get("steps", [])
255
+ gotchas = skill_data.get("gotchas", [])
256
+
257
+ # Build file content for the skill .md file
258
+ steps_md = "\n".join(f"{i+1}. {s}" for i, s in enumerate(steps))
259
+ gotchas_md = "\n".join(f"- {g}" for g in gotchas) if gotchas else "None"
260
+
261
+ conn = sqlite3.connect(str(NEXO_DB))
262
+ # Check if skill already exists
263
+ existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
264
+ if existing:
265
+ conn.close()
266
+ return {"success": False, "error": f"Skill {skill_id} already exists", "id": skill_id}
267
+
268
+ now = datetime.now().isoformat(timespec='seconds')
269
+ conn.execute(
270
+ """INSERT INTO skills
271
+ (id, name, description, level, trust_score, tags, trigger_patterns,
272
+ source_sessions, linked_learnings, created_at, updated_at)
273
+ VALUES (?, ?, ?, 'draft', 50, ?, ?, ?, '[]', ?, ?)""",
274
+ (skill_id, name, description, tags, trigger_patterns, source_sessions, now, now),
275
+ )
276
+ conn.commit()
277
+ conn.close()
278
+ return {"success": True, "id": skill_id, "name": name}
279
+ except Exception as e:
280
+ return {"success": False, "error": str(e)}
281
+
282
+
206
283
  def create_abandoned_followups(synthesis: dict) -> list[dict]:
207
284
  """Create followups for truly abandoned projects."""
208
285
  results = []
@@ -494,6 +571,11 @@ def apply_action(action: dict, run_id: str) -> dict:
494
571
  log_entry["status"] = "applied" if result.get("success") else "error"
495
572
  log_entry["details"] = result
496
573
 
574
+ elif action_type == "skill_create":
575
+ result = create_skill(content)
576
+ log_entry["status"] = "applied" if result.get("success") else "error"
577
+ log_entry["details"] = result
578
+
497
579
  elif action_type == "morning_briefing_item":
498
580
  # These are included in the briefing file, not applied separately
499
581
  log_entry["status"] = "included_in_briefing"
@@ -585,6 +667,26 @@ def main():
585
667
  else:
586
668
  print(f" Trust skip: {trust_result.get('error', '?')}")
587
669
 
670
+ # Create skills from synthesis
671
+ skills_data = synthesis.get("skills", [])
672
+ if skills_data:
673
+ print(f"[apply] Creating {len(skills_data)} skill(s)...")
674
+ for skill_data in skills_data:
675
+ if skill_data.get("confidence", 0) < 0.7:
676
+ continue
677
+ if skill_data.get("merge_with"):
678
+ print(f" Skip {skill_data.get('id', '?')}: merge candidate (needs runtime merge)")
679
+ continue
680
+ result = create_skill(skill_data)
681
+ if result.get("success"):
682
+ stats["applied"] += 1
683
+ print(f" Skill created: {result['id']} — {result.get('name', '')[:50]}")
684
+ elif "already exists" in result.get("error", ""):
685
+ stats["skipped_dedupe"] += 1
686
+ else:
687
+ stats["errors"] += 1
688
+ print(f" Skill error: {result.get('error', 'unknown')}", file=sys.stderr)
689
+
588
690
  # Create followups for abandoned projects
589
691
  abandoned_results = create_abandoned_followups(synthesis)
590
692
  for r in abandoned_results:
@@ -116,8 +116,15 @@ def extract_session(jsonl_path: Path) -> dict | None:
116
116
  }
117
117
 
118
118
 
119
- def collect_transcripts(target_date: str) -> list[dict]:
120
- """Collect all sessions modified on the target date."""
119
+ def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]:
120
+ """Collect all sessions modified after `since_iso` (exclusive) up to `until_iso` (inclusive).
121
+
122
+ Uses a watermark approach: deep sleep tracks the last processed timestamp
123
+ so nothing is missed regardless of when sessions happen (day, night, etc.).
124
+ """
125
+ since_dt = datetime.fromisoformat(since_iso)
126
+ until_dt = datetime.fromisoformat(until_iso) if until_iso else datetime.now()
127
+
121
128
  sessions = []
122
129
  for sdir in find_session_dirs():
123
130
  for f in sdir.glob("*.jsonl"):
@@ -125,7 +132,7 @@ def collect_transcripts(target_date: str) -> list[dict]:
125
132
  mtime = datetime.fromtimestamp(f.stat().st_mtime)
126
133
  except OSError:
127
134
  continue
128
- if mtime.strftime("%Y-%m-%d") == target_date:
135
+ if since_dt < mtime <= until_dt:
129
136
  session = extract_session(f)
130
137
  if session:
131
138
  session["modified"] = mtime.isoformat()
@@ -339,25 +346,40 @@ def format_transcripts(sessions: list[dict]) -> str:
339
346
 
340
347
 
341
348
  def main():
342
- target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
349
+ # Watermark-based collection: since_iso and until_iso passed by the wrapper script
350
+ # argv[1] = run_id (date label for output files)
351
+ # argv[2] = since_iso (exclusive lower bound, e.g. "2026-04-01T04:30:00")
352
+ # argv[3] = until_iso (inclusive upper bound, e.g. "2026-04-02T04:30:00") — optional, defaults to now
353
+ run_id = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
354
+ since_iso = sys.argv[2] if len(sys.argv) > 2 else ""
355
+ until_iso = sys.argv[3] if len(sys.argv) > 3 else ""
356
+
343
357
  DEEP_SLEEP_DIR.mkdir(parents=True, exist_ok=True)
344
358
 
345
- print(f"[collect] Phase 1: Collecting context for {target_date}")
359
+ print(f"[collect] Phase 1: Collecting context (run_id={run_id})")
346
360
 
347
- # 1. Transcripts
348
- print("[collect] Gathering transcripts...")
349
- sessions = collect_transcripts(target_date)
361
+ # 1. Transcripts — watermark-based
362
+ if since_iso:
363
+ print(f"[collect] Gathering transcripts since {since_iso}" + (f" until {until_iso}" if until_iso else ""))
364
+ sessions = collect_transcripts_since(since_iso, until_iso)
365
+ else:
366
+ # Fallback: collect everything from last 48h (safe catch-all)
367
+ fallback_since = (datetime.now() - timedelta(hours=48)).isoformat()
368
+ print(f"[collect] No watermark — collecting last 48h since {fallback_since}")
369
+ sessions = collect_transcripts_since(fallback_since)
350
370
  print(f" Found {len(sessions)} sessions")
351
371
 
352
372
  if not sessions:
353
- print(f"[collect] No sessions found for {target_date}. Writing minimal context file.")
354
- output_file = DEEP_SLEEP_DIR / f"{target_date}-context.txt"
373
+ print(f"[collect] No new sessions found. Writing minimal context file.")
374
+ output_file = DEEP_SLEEP_DIR / f"{run_id}-context.txt"
355
375
  output_file.write_text(
356
- f"Deep Sleep Context for {target_date}\n\nNo sessions found for this date.\n"
376
+ f"Deep Sleep Context for {run_id}\n\nNo sessions found.\n"
357
377
  )
358
378
  print(f"[collect] Output: {output_file}")
359
379
  return
360
380
 
381
+ target_date = run_id # Keep variable name for downstream compat
382
+
361
383
  # 2. Core DB data
362
384
  print("[collect] Querying databases...")
363
385
  followups = collect_followups()
@@ -58,6 +58,22 @@ Detect work that was started but not finished in this session:
58
58
  - Investigations started but conclusions never reached
59
59
  Only flag if the work was NOT captured in a followup or reminder.
60
60
 
61
+ ### 9. Skill Candidates (Reusable Procedures)
62
+ Detect multi-step tasks that were completed successfully and could be reused:
63
+ - Tasks that required 3+ distinct steps to complete
64
+ - Tasks where the agent followed a clear sequence of actions
65
+ - Procedures that are likely to be repeated in the future
66
+ - Examples: deploying code, configuring a service, running an audit, setting up infrastructure
67
+
68
+ For each candidate, extract:
69
+ - The full step-by-step procedure (what was actually done, in order)
70
+ - Tags describing the domain (e.g., "shopify", "chrome", "deploy")
71
+ - Trigger phrases that would indicate this procedure is needed (e.g., "deploy extension", "push theme")
72
+ - Any gotchas or warnings discovered during execution
73
+
74
+ Only flag if the procedure was SUCCESSFUL (the task was completed without major failures).
75
+ Do NOT flag trivial tasks (single-step actions, simple file edits, quick lookups).
76
+
61
77
  ### 8. Productivity Patterns
62
78
  Analyze how the session went in terms of efficiency:
63
79
  - How many times did the agent need correction before getting it right?
@@ -195,6 +211,28 @@ Return ONLY valid JSON. No markdown code fences. No explanation text before or a
195
211
  }
196
212
  ],
197
213
 
214
+ "skill_candidates": [
215
+ {
216
+ "name": "Short name for the procedure (e.g., Deploy Chrome Extension)",
217
+ "description": "What this procedure accomplishes (1-2 sentences)",
218
+ "steps": [
219
+ "Step 1: What was done first",
220
+ "Step 2: What was done next",
221
+ "Step 3: etc."
222
+ ],
223
+ "tags": ["domain1", "domain2"],
224
+ "trigger_phrases": ["phrase that would trigger this", "another trigger"],
225
+ "gotchas": ["Warning or caveat discovered during execution"],
226
+ "evidence": {
227
+ "type": "transcript",
228
+ "session_id": "filename.jsonl",
229
+ "message_index": 10,
230
+ "quote": "Start of the multi-step task"
231
+ },
232
+ "confidence": 0.85
233
+ }
234
+ ],
235
+
198
236
  "productivity_score": {
199
237
  "corrections_needed": 0,
200
238
  "proactivity": "reactive|mixed|proactive",
@@ -113,6 +113,14 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
113
113
  try:
114
114
  env = os.environ.copy()
115
115
  env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
116
+ env.pop("CLAUDECODE", None)
117
+ env.pop("CLAUDE_CODE", None)
118
+
119
+ JSON_SYSTEM_PROMPT = (
120
+ "You are a JSON-only analyst. Your ENTIRE response must be a single valid JSON object. "
121
+ "No text before it. No text after it. No markdown fences. No explanations. "
122
+ "If you want to summarize, put it inside the JSON fields. Start with { and end with }."
123
+ )
116
124
 
117
125
  result = subprocess.run(
118
126
  [
@@ -120,8 +128,9 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
120
128
  "-p", prompt,
121
129
  "--model", "opus",
122
130
  "--output-format", "text",
131
+ "--append-system-prompt", JSON_SYSTEM_PROMPT,
123
132
  "--allowedTools",
124
- "Read,Grep,Bash,mcp__nexo__nexo_startup,mcp__nexo__nexo_learning_search,mcp__nexo__nexo_recall"
133
+ "Read,Grep,Bash"
125
134
  ],
126
135
  capture_output=True,
127
136
  text=True,
@@ -139,6 +148,28 @@ def analyze_session(session_id: str, date_dir: Path, shared_context_file: Path |
139
148
  if not line.strip().startswith("Post-mortem") and line.strip()
140
149
  )
141
150
  parsed = extract_json_from_response(output)
151
+
152
+ # Fallback: if Claude returned text instead of JSON, ask a short conversion call
153
+ if not parsed and len(output.strip()) > 50:
154
+ print(f" Got text instead of JSON ({len(output)} chars). Converting...")
155
+ convert_prompt = (
156
+ f"Convert the following analysis into the exact JSON schema required. "
157
+ f"Return ONLY the JSON object, nothing else.\n\n"
158
+ f"Analysis:\n{output[:8000]}\n\n"
159
+ f"Required schema: session_id, findings[], emotional_timeline[], "
160
+ f"abandoned_projects[], skill_candidates[], productivity_score, protocol_summary"
161
+ )
162
+ convert_result = subprocess.run(
163
+ [claude_bin, "-p", convert_prompt, "--model", "sonnet",
164
+ "--output-format", "text",
165
+ "--append-system-prompt", JSON_SYSTEM_PROMPT],
166
+ capture_output=True, text=True, timeout=120, env=env
167
+ )
168
+ if convert_result.returncode == 0:
169
+ parsed = extract_json_from_response(convert_result.stdout)
170
+ if parsed:
171
+ print(f" Conversion succeeded")
172
+
142
173
  if not parsed:
143
174
  # Save raw output for debugging
144
175
  debug_file = DEEP_SLEEP_DIR / f"debug-extract-{session_id[:20]}.txt"
@@ -207,32 +238,70 @@ def main():
207
238
  print(f"[extract] Phase 2: Analyzing {len(session_files)} sessions for {target_date}")
208
239
  print(f"[extract] Claude CLI: {claude_bin}")
209
240
 
241
+ # Checkpoint directory: one JSON per session, survives crashes
242
+ checkpoint_dir = date_dir / "checkpoints"
243
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
244
+
210
245
  all_extractions = []
211
246
  total_findings = 0
247
+ skipped = 0
248
+ MAX_RETRIES = 3
212
249
 
213
250
  for i, session_id in enumerate(session_files):
251
+ sid_safe = session_id.replace(".jsonl", "")[:30]
252
+ checkpoint_file = checkpoint_dir / f"{sid_safe}.json"
253
+
254
+ # Resume: skip already-processed sessions
255
+ if checkpoint_file.exists():
256
+ try:
257
+ with open(checkpoint_file) as f:
258
+ cached = json.load(f)
259
+ findings_count = len(cached.get("findings", []))
260
+ total_findings += findings_count
261
+ all_extractions.append(cached)
262
+ skipped += 1
263
+ print(f"[extract] Session {i + 1}/{len(session_files)}: {session_id} (cached, {findings_count} findings)")
264
+ continue
265
+ except (json.JSONDecodeError, KeyError):
266
+ pass # Corrupted checkpoint, re-process
267
+
214
268
  print(f"[extract] Session {i + 1}/{len(session_files)}: {session_id}")
215
269
 
216
- result = analyze_session(session_id, date_dir, shared_context_file, claude_bin)
270
+ # Retry loop
271
+ result = None
272
+ for attempt in range(1, MAX_RETRIES + 1):
273
+ result = analyze_session(session_id, date_dir, shared_context_file, claude_bin)
274
+ if result:
275
+ break
276
+ if attempt < MAX_RETRIES:
277
+ print(f" -> Attempt {attempt}/{MAX_RETRIES} failed, retrying...")
217
278
 
218
279
  if result:
219
280
  findings_count = len(result.get("findings", []))
220
281
  total_findings += findings_count
221
282
  all_extractions.append(result)
222
- print(f" -> {findings_count} findings extracted")
283
+ # Save checkpoint
284
+ with open(checkpoint_file, "w") as f:
285
+ json.dump(result, f, indent=2, ensure_ascii=False)
286
+ print(f" -> {findings_count} findings extracted (checkpointed)")
223
287
  else:
224
- print(f" -> Extraction failed, continuing with next session")
225
- all_extractions.append({
288
+ print(f" -> Failed after {MAX_RETRIES} attempts, marking as failed")
289
+ failed_entry = {
226
290
  "session_id": session_id,
227
291
  "findings": [],
228
- "error": "Extraction failed"
229
- })
292
+ "error": f"Extraction failed after {MAX_RETRIES} attempts"
293
+ }
294
+ all_extractions.append(failed_entry)
295
+ # Save failed checkpoint too (so we don't retry forever)
296
+ with open(checkpoint_file, "w") as f:
297
+ json.dump(failed_entry, f, indent=2, ensure_ascii=False)
230
298
 
231
299
  # Merge into output
232
300
  output = {
233
301
  "date": target_date,
234
302
  "sessions_analyzed": len(session_files),
235
303
  "sessions_succeeded": len([e for e in all_extractions if "error" not in e]),
304
+ "sessions_cached": skipped,
236
305
  "total_findings": total_findings,
237
306
  "extractions": all_extractions
238
307
  }
@@ -241,7 +310,10 @@ def main():
241
310
  with open(output_file, "w") as f:
242
311
  json.dump(output, f, indent=2, ensure_ascii=False)
243
312
 
244
- print(f"\n[extract] Done. {total_findings} total findings from {len(session_files)} sessions.")
313
+ if skipped:
314
+ print(f"\n[extract] Done. {total_findings} findings from {len(session_files)} sessions ({skipped} cached, {len(session_files) - skipped} new).")
315
+ else:
316
+ print(f"\n[extract] Done. {total_findings} findings from {len(session_files)} sessions.")
245
317
  print(f"[extract] Output: {output_file}")
246
318
 
247
319
 
@@ -73,6 +73,19 @@ Consider ALL of these:
73
73
 
74
74
  The score should feel fair. A day with 2 minor corrections and 10 tasks completed is still a good day (75+). A day with 1 catastrophic error might be a 40 even if everything else was fine.
75
75
 
76
+ ### 9. Skill Extraction
77
+ Consolidate `skill_candidates` from all session extractions into publishable skills:
78
+ - Merge similar procedures from different sessions into a single skill
79
+ - Generalize: replace session-specific IDs, paths, or names with placeholders or descriptions
80
+ - Only include skills with confidence >= 0.7
81
+ - Check if a similar skill already exists (use `nexo_skill_match` if available) — if so, note it for merging instead of creating new
82
+
83
+ For each skill, generate:
84
+ - A unique ID starting with `SK-` (e.g., `SK-DEPLOY-CHROME-EXT`)
85
+ - Name, description, tags, trigger_patterns
86
+ - The full step-by-step procedure as the skill content
87
+ - Source session IDs for traceability
88
+
76
89
  ### 8. Consolidated Actions
77
90
  Merge and deduplicate all findings into a final action list. Each action should have:
78
91
  - `action_type`: `learning_add`, `followup_create`, `morning_briefing_item`
@@ -122,9 +135,24 @@ Return ONLY valid JSON. No markdown code fences. No explanation text.
122
135
  }
123
136
  ],
124
137
 
138
+ "skills": [
139
+ {
140
+ "id": "SK-SHORT-ID",
141
+ "name": "Human readable name",
142
+ "description": "What this procedure does (1-2 sentences)",
143
+ "steps": ["Step 1", "Step 2", "Step 3"],
144
+ "tags": ["tag1", "tag2"],
145
+ "trigger_patterns": ["trigger phrase 1", "trigger phrase 2"],
146
+ "gotchas": ["Warning or caveat"],
147
+ "source_sessions": ["session1.jsonl"],
148
+ "confidence": 0.85,
149
+ "merge_with": null
150
+ }
151
+ ],
152
+
125
153
  "actions": [
126
154
  {
127
- "action_type": "learning_add|followup_create|morning_briefing_item",
155
+ "action_type": "learning_add|followup_create|skill_create|morning_briefing_item",
128
156
  "action_class": "auto_apply|draft_for_morning",
129
157
  "confidence": 0.9,
130
158
  "impact": "low|medium|high",
@@ -115,6 +115,8 @@ def main():
115
115
  try:
116
116
  env = os.environ.copy()
117
117
  env["NEXO_HEADLESS"] = "1" # Skip stop hook post-mortem
118
+ env.pop("CLAUDECODE", None)
119
+ env.pop("CLAUDE_CODE", None)
118
120
 
119
121
  result = subprocess.run(
120
122
  [
@@ -123,7 +125,7 @@ def main():
123
125
  "--model", "opus",
124
126
  "--output-format", "text",
125
127
  "--allowedTools",
126
- "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__nexo_startup,mcp__nexo__nexo_learning_search,mcp__nexo__nexo_recall,mcp__nexo__nexo_reminders"
128
+ "Read,Grep,Bash"
127
129
  ],
128
130
  capture_output=True,
129
131
  text=True,
@@ -1,20 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- NEXO Catch-Up — Runs at Mac boot to execute any missed scheduled tasks.
3
+ NEXO Catch-Up — Runs at boot/wake to recover any missed scheduled tasks.
4
4
 
5
- When the Mac was asleep/off during scheduled times, launchd does NOT retry
6
- missed StartCalendarInterval jobs. This script detects what was missed and
7
- runs them in the correct order.
5
+ Tasks are loaded dynamically from crons/manifest.json (single source of truth).
6
+ Only scheduled crons (with hour/minute) are recovered interval-based crons
7
+ (immune, watchdog, auto-close) restart automatically via launchd/systemd.
8
8
 
9
- Scheduled tasks (ordered by intended run time):
10
- 03:00 cognitive-decay (Ebbinghaus decay + STM→LTM promotion)
11
- 03:00 evolution (weekly, Sundays only)
12
- 04:00 — sleep (session cleanup)
13
- 07:00 — self-audit (health checks + weekly cognitive GC on Sundays)
14
- 23:30 — postmortem (consolidation + sensory register)
15
-
16
- Logic: For each task, check if its last successful run was before the
17
- most recent scheduled time. If so, run it now.
9
+ Logic: For each scheduled task, check if its last successful run was before
10
+ the most recent scheduled time. If so, run it now. Only marks success on exit 0.
11
+ Uses cron/launchd weekday convention (0=Sunday) converted to Python (0=Monday).
18
12
  """
19
13
 
20
14
  import json
@@ -52,6 +46,49 @@ def _resolve_python() -> str:
52
46
  return sys.executable
53
47
 
54
48
  NEXO_PYTHON = _resolve_python()
49
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
50
+ MANIFEST = NEXO_CODE / "crons" / "manifest.json"
51
+
52
+
53
+ def _load_tasks_from_manifest() -> list[tuple]:
54
+ """Read scheduled tasks from manifest.json — single source of truth.
55
+
56
+ Only includes crons with a schedule (hour/minute). Excludes interval-based
57
+ crons (immune, watchdog, auto-close) and run_at_load (catchup itself).
58
+ Returns: list of (name, hour, minute, python_or_bash, script, weekday)
59
+ """
60
+ if not MANIFEST.exists():
61
+ log(f"WARNING: manifest not found at {MANIFEST}, using empty task list")
62
+ return []
63
+
64
+ with open(MANIFEST) as f:
65
+ data = json.load(f)
66
+
67
+ tasks = []
68
+ for cron in data.get("crons", []):
69
+ schedule = cron.get("schedule")
70
+ if not schedule or "hour" not in schedule:
71
+ continue # Skip interval-based and run_at_load crons
72
+ if cron["id"] == "catchup":
73
+ continue # Don't catch up ourselves
74
+
75
+ script = cron["script"]
76
+ script_type = cron.get("type", "python")
77
+ interpreter = NEXO_PYTHON if script_type == "python" else "/bin/bash"
78
+ weekday = schedule.get("weekday")
79
+
80
+ tasks.append((
81
+ cron["id"],
82
+ schedule["hour"],
83
+ schedule["minute"],
84
+ interpreter,
85
+ Path(script).name,
86
+ weekday,
87
+ ))
88
+
89
+ # Sort by hour, minute for correct execution order
90
+ tasks.sort(key=lambda t: (t[1], t[2]))
91
+ return tasks
55
92
 
56
93
 
57
94
  def log(msg: str):
@@ -83,7 +120,11 @@ def last_scheduled_time(hour: int, minute: int, weekday: int = None) -> datetime
83
120
 
84
121
  if weekday is not None:
85
122
  # Weekly task — find the most recent matching weekday
86
- days_since = (now.weekday() - weekday) % 7
123
+ # Manifest uses cron/launchd convention: 0=Sunday, 6=Saturday
124
+ # Python datetime.weekday() uses: 0=Monday, 6=Sunday
125
+ # Convert: manifest 0 (Sun) -> python 6, manifest 1 (Mon) -> python 0, etc.
126
+ py_weekday = (weekday - 1) % 7
127
+ days_since = (now.weekday() - py_weekday) % 7
87
128
  target = now - timedelta(days=days_since)
88
129
  target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
89
130
  if target > now:
@@ -130,13 +171,14 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
130
171
  )
131
172
  if result.returncode == 0:
132
173
  log(f" OK {name} (exit 0)")
174
+ state[name] = datetime.now().isoformat()
175
+ save_state(state)
176
+ return True
133
177
  else:
134
- log(f" WARN {name} (exit {result.returncode})")
178
+ log(f" FAIL {name} (exit {result.returncode})")
135
179
  if result.stderr:
136
180
  log(f" stderr: {result.stderr[:300]}")
137
- state[name] = datetime.now().isoformat()
138
- save_state(state)
139
- return True
181
+ return False
140
182
  except subprocess.TimeoutExpired:
141
183
  log(f" TIMEOUT {name} (300s)")
142
184
  return False
@@ -149,17 +191,8 @@ def main():
149
191
  log("=== NEXO Catch-Up starting (boot/wake) ===")
150
192
  state = load_state()
151
193
 
152
- # Define tasks in execution order (matching their intended schedule order)
153
- # Note: auto-update is handled by the MCP server on startup, not by catchup.
154
- tasks = [
155
- # (name, hour, minute, python, script, weekday)
156
- ("cognitive-decay", 3, 0, NEXO_PYTHON, "nexo-cognitive-decay.py", None),
157
- ("evolution", 3, 0, NEXO_PYTHON, "nexo-evolution-run.py", 6), # Sunday = 6
158
- ("sleep", 4, 0, NEXO_PYTHON, "nexo-sleep.py", None),
159
- ("self-audit", 7, 0, NEXO_PYTHON, "nexo-daily-self-audit.py", None),
160
- ("github-monitor", 8, 0, NEXO_PYTHON, "nexo-github-monitor.py", None),
161
- ("postmortem", 23, 30, NEXO_PYTHON, "nexo-postmortem-consolidator.py", None),
162
- ]
194
+ # Read tasks from manifest single source of truth
195
+ tasks = _load_tasks_from_manifest()
163
196
 
164
197
  ran = 0
165
198
  skipped = 0
@@ -187,6 +220,9 @@ def _cli_post_catchup_assessment(ran: int, skipped: int, state: dict):
187
220
  if not CLAUDE_CLI.exists():
188
221
  log(f"Caught up {ran} tasks, {skipped} already current. (CLI unavailable for assessment)")
189
222
  return
223
+ auth_check = subprocess.run(
224
+ [str(CLAUDE_CLI), "-p", "reply OK", "--output-format", "text"],
225
+ capture_output=True, text=True, timeout=30
190
226
  )
191
227
  if auth_check.returncode != 0:
192
228
  # CLI not authenticated, skip gracefully