nexo-brain 2.4.0 → 2.5.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 (80) hide show
  1. package/README.md +65 -2
  2. package/bin/nexo-brain.js +208 -11
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +5 -2
  6. package/src/auto_update.py +158 -8
  7. package/src/cli.py +605 -0
  8. package/src/cognitive/_ingest.py +1 -1
  9. package/src/cognitive/_memory.py +4 -4
  10. package/src/crons/manifest.json +8 -0
  11. package/src/dashboard/app.py +700 -35
  12. package/src/dashboard/templates/adaptive.html +112 -218
  13. package/src/dashboard/templates/artifacts.html +133 -0
  14. package/src/dashboard/templates/backups.html +136 -0
  15. package/src/dashboard/templates/base.html +413 -0
  16. package/src/dashboard/templates/calendar.html +523 -654
  17. package/src/dashboard/templates/chat.html +356 -0
  18. package/src/dashboard/templates/claims.html +259 -0
  19. package/src/dashboard/templates/cortex.html +262 -0
  20. package/src/dashboard/templates/credentials.html +128 -0
  21. package/src/dashboard/templates/crons.html +370 -0
  22. package/src/dashboard/templates/dashboard.html +383 -578
  23. package/src/dashboard/templates/dreams.html +252 -0
  24. package/src/dashboard/templates/email.html +160 -0
  25. package/src/dashboard/templates/evolution.html +189 -0
  26. package/src/dashboard/templates/feed.html +249 -0
  27. package/src/dashboard/templates/followup_health.html +170 -0
  28. package/src/dashboard/templates/graph.html +191 -269
  29. package/src/dashboard/templates/guard.html +259 -0
  30. package/src/dashboard/templates/inbox.html +220 -346
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +521 -698
  33. package/src/dashboard/templates/plugins.html +185 -0
  34. package/src/dashboard/templates/rules.html +246 -0
  35. package/src/dashboard/templates/sentiment.html +247 -0
  36. package/src/dashboard/templates/sessions.html +215 -182
  37. package/src/dashboard/templates/skills.html +329 -0
  38. package/src/dashboard/templates/somatic.html +68 -172
  39. package/src/dashboard/templates/triggers.html +133 -0
  40. package/src/dashboard/templates/trust.html +360 -0
  41. package/src/db/__init__.py +5 -0
  42. package/src/db/_schema.py +16 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +980 -274
  45. package/src/doctor/__init__.py +1 -0
  46. package/src/doctor/formatters.py +52 -0
  47. package/src/doctor/models.py +44 -0
  48. package/src/doctor/orchestrator.py +42 -0
  49. package/src/doctor/providers/__init__.py +1 -0
  50. package/src/doctor/providers/boot.py +206 -0
  51. package/src/doctor/providers/deep.py +292 -0
  52. package/src/doctor/providers/runtime.py +686 -0
  53. package/src/hooks/post-compact.sh +5 -1
  54. package/src/hooks/pre-compact.sh +1 -1
  55. package/src/plugins/doctor.py +36 -0
  56. package/src/plugins/evolution.py +2 -1
  57. package/src/plugins/skills.py +135 -175
  58. package/src/requirements.txt +1 -0
  59. package/src/script_registry.py +322 -0
  60. package/src/scripts/deep-sleep/apply_findings.py +63 -48
  61. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  62. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  63. package/src/scripts/deep-sleep/synthesize.py +37 -1
  64. package/src/scripts/nexo-dashboard.sh +29 -0
  65. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  66. package/src/scripts/nexo-evolution-run.py +2 -1
  67. package/src/scripts/nexo-learning-housekeep.py +1 -1
  68. package/src/scripts/nexo-watchdog.sh +1 -1
  69. package/src/server.py +9 -5
  70. package/src/skills/run-runtime-doctor/guide.md +12 -0
  71. package/src/skills/run-runtime-doctor/script.py +21 -0
  72. package/src/skills/run-runtime-doctor/skill.json +25 -0
  73. package/src/skills_runtime.py +347 -0
  74. package/src/tools_menu.py +3 -2
  75. package/src/tools_sessions.py +126 -0
  76. package/src/user_context.py +46 -0
  77. package/templates/nexo_helper.py +45 -0
  78. package/templates/script-template.py +44 -0
  79. package/templates/skill-script-template.py +39 -0
  80. package/templates/skill-template.md +33 -0
@@ -0,0 +1,322 @@
1
+ """NEXO Script Registry — discovery, metadata, validation for personal scripts.
2
+
3
+ Scripts live in NEXO_HOME/scripts/. Core scripts (from manifest) are filtered by default.
4
+ Personal scripts use CLI as stable interface, never direct DB access.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
17
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
18
+
19
+ # Internal artifacts to always ignore
20
+ _IGNORED_FILES = {
21
+ ".watchdog-hashes",
22
+ ".watchdog-fails",
23
+ ".watchdog-nexo-repair.lock",
24
+ "nexo-cron-wrapper.sh",
25
+ }
26
+ _IGNORED_DIRS = {"deep-sleep", "__pycache__"}
27
+
28
+ # Forbidden patterns — direct DB access from personal scripts
29
+ _FORBIDDEN_PATTERNS = [
30
+ re.compile(r"\bsqlite3\b"),
31
+ re.compile(r"\bnexo\.db\b"),
32
+ re.compile(r"\bcognitive\.db\b"),
33
+ re.compile(r"/data/nexo\.db"),
34
+ re.compile(r"/data/cognitive\.db"),
35
+ re.compile(r"\bimport\s+db\b"),
36
+ re.compile(r"\bfrom\s+db\s+import\b"),
37
+ re.compile(r"\bimport\s+cognitive\b"),
38
+ re.compile(r"\bfrom\s+cognitive\s+import\b"),
39
+ ]
40
+
41
+ METADATA_KEYS = {"name", "description", "runtime", "timeout", "requires", "tools", "hidden"}
42
+
43
+
44
+ def get_nexo_home() -> Path:
45
+ return NEXO_HOME
46
+
47
+
48
+ def get_scripts_dir() -> Path:
49
+ return NEXO_HOME / "scripts"
50
+
51
+
52
+ def load_core_script_names() -> set[str]:
53
+ """Load script names from crons/manifest.json (these are core, not personal)."""
54
+ names: set[str] = set()
55
+ for manifest_path in [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]:
56
+ if manifest_path.exists():
57
+ try:
58
+ data = json.loads(manifest_path.read_text())
59
+ for cron in data.get("crons", []):
60
+ script = cron.get("script", "")
61
+ # script is like "scripts/nexo-immune.py" — extract filename
62
+ names.add(Path(script).name)
63
+ break
64
+ except Exception:
65
+ continue
66
+ return names
67
+
68
+
69
+ def parse_inline_metadata(path: Path) -> dict:
70
+ """Parse # nexo: key=value metadata from first 25 lines."""
71
+ meta: dict[str, str] = {}
72
+ try:
73
+ lines = path.read_text(errors="ignore").splitlines()[:25]
74
+ except Exception:
75
+ return meta
76
+
77
+ for line in lines:
78
+ stripped = line.strip()
79
+ if not stripped.startswith("# nexo:"):
80
+ continue
81
+ payload = stripped[len("# nexo:"):].strip()
82
+ if "=" not in payload:
83
+ continue
84
+ key, value = payload.split("=", 1)
85
+ k = key.strip()
86
+ if k in METADATA_KEYS:
87
+ meta[k] = value.strip()
88
+ return meta
89
+
90
+
91
+ def _detect_shebang(path: Path) -> str | None:
92
+ """Read first line for shebang."""
93
+ try:
94
+ first = path.read_text(errors="ignore").split("\n", 1)[0]
95
+ if first.startswith("#!"):
96
+ return first
97
+ except Exception:
98
+ pass
99
+ return None
100
+
101
+
102
+ def classify_runtime(path: Path, metadata: dict) -> str:
103
+ """Detect script runtime: python, shell, or unknown."""
104
+ # 1. Metadata
105
+ rt = metadata.get("runtime", "").lower()
106
+ if rt in ("python", "shell"):
107
+ return rt
108
+
109
+ # 2. Shebang
110
+ shebang = _detect_shebang(path)
111
+ if shebang:
112
+ if "python" in shebang:
113
+ return "python"
114
+ if "bash" in shebang or "/sh" in shebang:
115
+ return "shell"
116
+
117
+ # 3. Extension
118
+ ext = path.suffix.lower()
119
+ if ext == ".py":
120
+ return "python"
121
+ if ext == ".sh":
122
+ return "shell"
123
+
124
+ return "unknown"
125
+
126
+
127
+ def _is_ignored(path: Path) -> bool:
128
+ """Check if file should be ignored entirely."""
129
+ if path.name in _IGNORED_FILES:
130
+ return True
131
+ if path.name.startswith("."):
132
+ return True
133
+ for parent in path.relative_to(get_scripts_dir()).parents:
134
+ if parent.name in _IGNORED_DIRS:
135
+ return True
136
+ return False
137
+
138
+
139
+ def list_scripts(include_core: bool = False) -> list[dict]:
140
+ """List scripts in NEXO_HOME/scripts/.
141
+
142
+ By default only personal scripts. With include_core=True, also shows core/cron scripts.
143
+ """
144
+ scripts_dir = get_scripts_dir()
145
+ if not scripts_dir.is_dir():
146
+ return []
147
+
148
+ core_names = load_core_script_names()
149
+ results = []
150
+
151
+ for f in sorted(scripts_dir.iterdir()):
152
+ if not f.is_file():
153
+ continue
154
+ if _is_ignored(f):
155
+ continue
156
+
157
+ is_core = f.name in core_names
158
+ if is_core and not include_core:
159
+ continue
160
+
161
+ meta = parse_inline_metadata(f)
162
+ runtime = classify_runtime(f, meta)
163
+ name = meta.get("name", f.stem)
164
+ hidden = meta.get("hidden", "").lower() in ("true", "1", "yes")
165
+
166
+ if hidden and not include_core:
167
+ continue
168
+
169
+ results.append({
170
+ "name": name,
171
+ "runtime": runtime,
172
+ "description": meta.get("description", ""),
173
+ "path": str(f),
174
+ "core": is_core,
175
+ "metadata": meta,
176
+ })
177
+
178
+ return results
179
+
180
+
181
+ def resolve_script(name: str) -> dict | None:
182
+ """Find a script by name (metadata name or filename stem)."""
183
+ scripts_dir = get_scripts_dir()
184
+ if not scripts_dir.is_dir():
185
+ return None
186
+
187
+ for f in scripts_dir.iterdir():
188
+ if not f.is_file() or _is_ignored(f):
189
+ continue
190
+ meta = parse_inline_metadata(f)
191
+ script_name = meta.get("name", f.stem)
192
+ if script_name == name or f.stem == name:
193
+ runtime = classify_runtime(f, meta)
194
+ return {
195
+ "name": script_name,
196
+ "runtime": runtime,
197
+ "description": meta.get("description", ""),
198
+ "path": str(f),
199
+ "core": f.name in load_core_script_names(),
200
+ "metadata": meta,
201
+ }
202
+ return None
203
+
204
+
205
+ def resolve_script_reference(ref: str) -> dict | None:
206
+ """Resolve a script by name or by direct filesystem path."""
207
+ direct = Path(ref)
208
+ if direct.is_file():
209
+ meta = parse_inline_metadata(direct)
210
+ return {
211
+ "name": meta.get("name", direct.stem),
212
+ "runtime": classify_runtime(direct, meta),
213
+ "description": meta.get("description", ""),
214
+ "path": str(direct),
215
+ "core": direct.name in load_core_script_names(),
216
+ "metadata": meta,
217
+ }
218
+ return resolve_script(ref)
219
+
220
+
221
+ def doctor_script(path_or_name: str) -> dict:
222
+ """Validate a single script. Returns dict with pass/warn/fail items."""
223
+ # Resolve
224
+ p = Path(path_or_name)
225
+ if not p.is_file():
226
+ info = resolve_script(path_or_name)
227
+ if not info:
228
+ return {"status": "fail", "items": [{"level": "fail", "msg": f"Script not found: {path_or_name}"}]}
229
+ p = Path(info["path"])
230
+
231
+ items: list[dict] = []
232
+ meta = parse_inline_metadata(p)
233
+ runtime = classify_runtime(p, meta)
234
+ core_names = load_core_script_names()
235
+ is_core = p.name in core_names
236
+
237
+ # File exists
238
+ if p.is_file():
239
+ items.append({"level": "pass", "msg": f"File exists: {p.name}"})
240
+ else:
241
+ items.append({"level": "fail", "msg": f"File missing: {p.name}"})
242
+ return {"status": "fail", "items": items}
243
+
244
+ # Name collision with core
245
+ name = meta.get("name", p.stem)
246
+ if not is_core:
247
+ for core in core_names:
248
+ core_stem = Path(core).stem
249
+ if name == core_stem:
250
+ items.append({"level": "fail", "msg": f"Name collision with core script: {core}"})
251
+
252
+ # Runtime recognized
253
+ if runtime == "unknown":
254
+ items.append({"level": "warn", "msg": "Runtime not recognized (no shebang, no extension match)"})
255
+ else:
256
+ items.append({"level": "pass", "msg": f"Runtime: {runtime}"})
257
+
258
+ # Shebang for shell scripts
259
+ if runtime == "shell":
260
+ shebang = _detect_shebang(p)
261
+ if not shebang:
262
+ items.append({"level": "warn", "msg": "Shell script without shebang"})
263
+ else:
264
+ items.append({"level": "pass", "msg": f"Shebang: {shebang}"})
265
+
266
+ # Executable bit for shell scripts
267
+ if runtime == "shell":
268
+ mode = p.stat().st_mode
269
+ if not (mode & stat.S_IXUSR):
270
+ items.append({"level": "warn", "msg": "Shell script missing executable bit"})
271
+ else:
272
+ items.append({"level": "pass", "msg": "Executable bit set"})
273
+
274
+ # Timeout parse
275
+ timeout_str = meta.get("timeout", "")
276
+ if timeout_str:
277
+ try:
278
+ int(timeout_str)
279
+ items.append({"level": "pass", "msg": f"Timeout: {timeout_str}s"})
280
+ except ValueError:
281
+ items.append({"level": "fail", "msg": f"Invalid timeout value: {timeout_str}"})
282
+
283
+ # Requires check
284
+ requires = meta.get("requires", "")
285
+ if requires:
286
+ for cmd in requires.split(","):
287
+ cmd = cmd.strip()
288
+ if cmd and not shutil.which(cmd):
289
+ items.append({"level": "fail", "msg": f"Required command not in PATH: {cmd}"})
290
+ elif cmd:
291
+ items.append({"level": "pass", "msg": f"Required command found: {cmd}"})
292
+
293
+ # Forbidden patterns (only for personal scripts)
294
+ if not is_core:
295
+ try:
296
+ content = p.read_text(errors="ignore")
297
+ for pat in _FORBIDDEN_PATTERNS:
298
+ match = pat.search(content)
299
+ if match:
300
+ items.append({"level": "fail", "msg": f"Forbidden DB pattern found: {match.group()}"})
301
+ except Exception:
302
+ pass
303
+
304
+ # Determine overall status
305
+ levels = [i["level"] for i in items]
306
+ if "fail" in levels:
307
+ status = "fail"
308
+ elif "warn" in levels:
309
+ status = "warn"
310
+ else:
311
+ status = "pass"
312
+
313
+ return {"status": status, "items": items, "name": name, "path": str(p)}
314
+
315
+
316
+ def doctor_all_scripts() -> list[dict]:
317
+ """Run doctor on all personal scripts."""
318
+ results = []
319
+ for script in list_scripts(include_core=False):
320
+ result = doctor_script(script["path"])
321
+ results.append(result)
322
+ return results
@@ -23,6 +23,10 @@ from datetime import datetime, timedelta
23
23
  from pathlib import Path
24
24
 
25
25
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
26
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
27
+ if str(NEXO_CODE) not in sys.path:
28
+ sys.path.insert(0, str(NEXO_CODE))
29
+
26
30
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
27
31
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
28
32
  COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
@@ -236,62 +240,53 @@ def calibrate_trust_score(synthesis: dict, target_date: str) -> dict:
236
240
 
237
241
 
238
242
  def create_skill(skill_data: dict) -> dict:
239
- """Create a skill in nexo.db from Deep Sleep extraction."""
240
- if not NEXO_DB.exists():
241
- return {"success": False, "error": "nexo.db not found"}
243
+ """Create a personal Skill v2 definition and sync it into SQLite."""
242
244
  try:
243
- import hashlib
245
+ from db import materialize_personal_skill_definition
246
+
244
247
  skill_id = skill_data.get("id", "")
245
248
  if not skill_id:
246
249
  skill_id = "SK-DS-" + hashlib.md5(
247
250
  skill_data.get("name", "").encode()
248
251
  ).hexdigest()[:8].upper()
249
252
 
250
- name = skill_data.get("name", "")
251
- description = skill_data.get("description", "")
252
- tags = json.dumps(skill_data.get("tags", []))
253
- trigger_patterns = json.dumps(skill_data.get("trigger_patterns", []))
254
- source_sessions = json.dumps(skill_data.get("source_sessions", []))
255
- steps = skill_data.get("steps", [])
256
- gotchas = skill_data.get("gotchas", [])
257
-
258
- # Build file content for the skill .md file
259
- steps_md = "\n".join(f"{i+1}. {s}" for i, s in enumerate(steps))
260
- gotchas_md = "\n".join(f"- {g}" for g in gotchas) if gotchas else "None"
261
-
262
- conn = sqlite3.connect(str(NEXO_DB))
263
- # Check if skill already exists
264
- existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
265
- if existing:
266
- conn.close()
267
- return {"success": False, "error": f"Skill {skill_id} already exists", "id": skill_id}
268
-
269
- now = datetime.now().isoformat(timespec='seconds')
270
- steps_json = json.dumps(steps) if isinstance(steps, list) else steps
271
- gotchas_json = json.dumps(gotchas) if isinstance(gotchas, list) else gotchas
272
-
273
- # Build markdown content from steps + gotchas
274
- content_lines = [f"# {name}", "", description, "", "## Steps"]
275
- for i, s in enumerate(steps if isinstance(steps, list) else json.loads(steps_json), 1):
276
- content_lines.append(f"{i}. {s}")
277
- gotchas_list = gotchas if isinstance(gotchas, list) else json.loads(gotchas_json)
278
- if gotchas_list:
279
- content_lines.extend(["", "## Gotchas"])
280
- for g in gotchas_list:
281
- content_lines.append(f"- {g}")
282
- content = "\n".join(content_lines)
283
-
284
- conn.execute(
285
- """INSERT INTO skills
286
- (id, name, description, level, trust_score, tags, trigger_patterns,
287
- source_sessions, linked_learnings, content, steps, gotchas, created_at, updated_at)
288
- VALUES (?, ?, ?, 'draft', 50, ?, ?, ?, '[]', ?, ?, ?, ?, ?)""",
289
- (skill_id, name, description, tags, trigger_patterns, source_sessions,
290
- content, steps_json, gotchas_json, now, now),
253
+ execution_level = skill_data.get("execution_level", "")
254
+ scriptable = bool(skill_data.get("scriptable"))
255
+ mode = skill_data.get("mode", "")
256
+ if not mode:
257
+ if scriptable and execution_level == "read-only":
258
+ mode = "hybrid"
259
+ else:
260
+ mode = "guide"
261
+
262
+ approval_required = bool(skill_data.get("approval_required", execution_level in {"local", "remote"}))
263
+ script_body = str(skill_data.get("script_body", "") or "")
264
+ executable_entry = str(skill_data.get("executable_entry", "") or "")
265
+
266
+ result = materialize_personal_skill_definition(
267
+ {
268
+ "id": skill_id,
269
+ "name": skill_data.get("name", ""),
270
+ "description": skill_data.get("description", ""),
271
+ "level": skill_data.get("level", "draft"),
272
+ "mode": mode,
273
+ "execution_level": execution_level if mode != "guide" else "none",
274
+ "approval_required": approval_required,
275
+ "tags": skill_data.get("tags", []),
276
+ "trigger_patterns": skill_data.get("trigger_patterns", []),
277
+ "source_sessions": skill_data.get("source_sessions", []),
278
+ "steps": skill_data.get("steps", []),
279
+ "gotchas": skill_data.get("gotchas", []),
280
+ "params_schema": skill_data.get("params_schema", skill_data.get("candidate_params", {})),
281
+ "command_template": skill_data.get("command_template", {}),
282
+ "executable_entry": executable_entry,
283
+ "script_body": script_body,
284
+ "content": skill_data.get("content", ""),
285
+ }
291
286
  )
292
- conn.commit()
293
- conn.close()
294
- return {"success": True, "id": skill_id, "name": name}
287
+ if "error" in result:
288
+ return {"success": False, "error": result["error"], "id": skill_id}
289
+ return {"success": True, "id": result["id"], "name": result.get("name", "")}
295
290
  except Exception as e:
296
291
  return {"success": False, "error": str(e)}
297
292
 
@@ -703,6 +698,26 @@ def main():
703
698
  stats["errors"] += 1
704
699
  print(f" Skill error: {result.get('error', 'unknown')}", file=sys.stderr)
705
700
 
701
+ evolution_candidates = synthesis.get("skill_evolution_candidates", [])
702
+ if evolution_candidates:
703
+ evolution_file = DEEP_SLEEP_DIR / f"{target_date}-skill-evolution-candidates.json"
704
+ with open(evolution_file, "w") as f:
705
+ json.dump(evolution_candidates, f, indent=2, ensure_ascii=False)
706
+ print(f" Skill evolution candidates: {evolution_file}")
707
+
708
+ try:
709
+ from skills_runtime import auto_promote_skill_evolution
710
+
711
+ promotion_result = auto_promote_skill_evolution()
712
+ if promotion_result.get("promoted"):
713
+ promotion_file = DEEP_SLEEP_DIR / f"{target_date}-skill-autopromotions.json"
714
+ with open(promotion_file, "w") as f:
715
+ json.dump(promotion_result, f, indent=2, ensure_ascii=False)
716
+ stats["applied"] += len(promotion_result["promoted"])
717
+ print(f" Skill autopromotions: {len(promotion_result['promoted'])} → {promotion_file}")
718
+ except Exception as e:
719
+ print(f" Skill autopromotion error: {e}", file=sys.stderr)
720
+
706
721
  # Create followups for abandoned projects
707
722
  abandoned_results = create_abandoned_followups(synthesis)
708
723
  for r in abandoned_results:
@@ -70,6 +70,10 @@ For each candidate, extract:
70
70
  - Tags describing the domain (e.g., "shopify", "chrome", "deploy")
71
71
  - Trigger phrases that would indicate this procedure is needed (e.g., "deploy extension", "push theme")
72
72
  - Any gotchas or warnings discovered during execution
73
+ - Whether the procedure looks scriptable (`scriptable: true|false`)
74
+ - Automation scope: `read-only|local|remote`
75
+ - Candidate params if the procedure clearly has repeated inputs (e.g. store, version, environment)
76
+ - Which steps are automatable vs still manual
73
77
 
74
78
  Only flag if the procedure was SUCCESSFUL (the task was completed without major failures).
75
79
  Do NOT flag trivial tasks (single-step actions, simple file edits, quick lookups).
@@ -223,6 +227,16 @@ Return ONLY valid JSON. No markdown code fences. No explanation text before or a
223
227
  "tags": ["domain1", "domain2"],
224
228
  "trigger_phrases": ["phrase that would trigger this", "another trigger"],
225
229
  "gotchas": ["Warning or caveat discovered during execution"],
230
+ "scriptable": false,
231
+ "automation_scope": "read-only|local|remote",
232
+ "candidate_params": {
233
+ "param_name": {
234
+ "type": "string|integer|number|boolean",
235
+ "required": true
236
+ }
237
+ },
238
+ "automatable_steps": ["Step that could be automated"],
239
+ "manual_steps": ["Step that still needs human judgment"],
226
240
  "evidence": {
227
241
  "type": "transcript",
228
242
  "session_id": "filename.jsonl",
@@ -10,6 +10,8 @@ FIRST: Call `nexo_startup` with `task='deep-sleep synthesis'` to initialize the
10
10
 
11
11
  Read the extractions file provided below. It contains per-session findings including corrections, self-corrected errors, unformalised ideas, missed commitments, and protocol violations.
12
12
 
13
+ Also read the runtime skill candidate file at `{{SKILL_RUNTIME_FILE}}`. It contains mature guide skills with repeated successful usage and candidates for automatic text→script evolution.
14
+
13
15
  Synthesize across all sessions:
14
16
 
15
17
  ### 1. Cross-Session Patterns
@@ -79,12 +81,23 @@ Consolidate `skill_candidates` from all session extractions into publishable ski
79
81
  - Generalize: replace session-specific IDs, paths, or names with placeholders or descriptions
80
82
  - Only include skills with confidence >= 0.7
81
83
  - Check if a similar skill already exists (use `nexo_skill_match` if available) — if so, note it for merging instead of creating new
84
+ - Prefer `mode=guide` unless there is strong evidence that some steps are safely automatable
85
+ - NEXO is now fully autonomous. Do NOT require manual approval gates in your output.
86
+ - When `{{SKILL_RUNTIME_FILE}}` contains mature `scriptable` candidates, prefer evolving the existing skill in place:
87
+ - reuse the same `id`
88
+ - keep the guide content, steps, gotchas, and triggers
89
+ - emit a concrete `script_body`
90
+ - set `mode=hybrid` or `mode=execute`
91
+ - set `execution_level` to the suggested scope (`read-only`, `local`, or `remote`)
92
+ - set `approval_required=false`
93
+ - If the likely scope is `local` or `remote`, you may still emit it as executable if the procedure is concrete and repeatable. If uncertain, keep it in `skill_evolution_candidates`.
82
94
 
83
95
  For each skill, generate:
84
96
  - A unique ID starting with `SK-` (e.g., `SK-DEPLOY-CHROME-EXT`)
85
97
  - Name, description, tags, trigger_patterns
86
98
  - The full step-by-step procedure as the skill content
87
99
  - Source session IDs for traceability
100
+ - When executable: include `command_template`, `executable_entry`, and `script_body`
88
101
 
89
102
  ### 8. Consolidated Actions
90
103
  Merge and deduplicate all findings into a final action list. Each action should have:
@@ -144,12 +157,35 @@ Return ONLY valid JSON. No markdown code fences. No explanation text.
144
157
  "tags": ["tag1", "tag2"],
145
158
  "trigger_patterns": ["trigger phrase 1", "trigger phrase 2"],
146
159
  "gotchas": ["Warning or caveat"],
160
+ "mode": "guide|execute|hybrid",
161
+ "execution_level": "none|read-only|local|remote",
162
+ "approval_required": false,
163
+ "params_schema": {
164
+ "param_name": {"type": "string", "required": true}
165
+ },
166
+ "command_template": {
167
+ "argv": ["script.py", "{{param_name}}"]
168
+ },
169
+ "executable_entry": "script.py",
170
+ "script_body": "#!/usr/bin/env python3\n...",
147
171
  "source_sessions": ["session1.jsonl"],
148
172
  "confidence": 0.85,
149
173
  "merge_with": null
150
174
  }
151
175
  ],
152
176
 
177
+ "skill_evolution_candidates": [
178
+ {
179
+ "id": "SK-EXISTING-ID",
180
+ "reason": "Used successfully 3+ times without major corrections",
181
+ "suggested_mode": "hybrid",
182
+ "suggested_execution_level": "read-only|local|remote",
183
+ "approval_required": true,
184
+ "params_schema": {},
185
+ "script_brief": "What a future script should automate"
186
+ }
187
+ ],
188
+
153
189
  "actions": [
154
190
  {
155
191
  "action_type": "learning_add|followup_create|skill_create|morning_briefing_item",
@@ -19,9 +19,13 @@ from datetime import datetime
19
19
  from pathlib import Path
20
20
 
21
21
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
22
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
22
23
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
23
24
  PROMPT_FILE = Path(__file__).parent / "synthesize-prompt.md"
24
25
 
26
+ if str(NEXO_CODE) not in sys.path:
27
+ sys.path.insert(0, str(NEXO_CODE))
28
+
25
29
  CLAUDE_TIMEOUT = 21600 # 3h safety net (prevents zombie processes)
26
30
 
27
31
 
@@ -71,6 +75,31 @@ def extract_json_from_response(text: str) -> dict | None:
71
75
  return None
72
76
 
73
77
 
78
+ def collect_skill_runtime_candidates(target_date: str) -> tuple[Path, dict]:
79
+ """Collect mature skill candidates from DB usage so Deep Sleep can evolve them."""
80
+ output_file = DEEP_SLEEP_DIR / f"{target_date}-skill-runtime-candidates.json"
81
+ payload = {
82
+ "scriptable": [],
83
+ "improvements": [],
84
+ }
85
+ try:
86
+ from db import (
87
+ collect_scriptable_skill_candidates,
88
+ collect_skill_improvement_candidates,
89
+ init_db,
90
+ )
91
+
92
+ init_db()
93
+ payload["scriptable"] = collect_scriptable_skill_candidates()
94
+ payload["improvements"] = collect_skill_improvement_candidates()
95
+ except Exception as e:
96
+ payload["error"] = str(e)
97
+
98
+ with open(output_file, "w") as f:
99
+ json.dump(payload, f, indent=2, ensure_ascii=False)
100
+ return output_file, payload
101
+
102
+
74
103
  def main():
75
104
  target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
76
105
 
@@ -86,7 +115,10 @@ def main():
86
115
  extractions = json.load(f)
87
116
 
88
117
  total_findings = extractions.get("total_findings", 0)
89
- if total_findings == 0:
118
+ runtime_candidates_file, runtime_candidates = collect_skill_runtime_candidates(target_date)
119
+ runtime_candidate_count = len(runtime_candidates.get("scriptable", [])) + len(runtime_candidates.get("improvements", []))
120
+
121
+ if total_findings == 0 and runtime_candidate_count == 0:
90
122
  print(f"[synthesize] No findings to synthesize for {target_date}.")
91
123
  # Write minimal synthesis
92
124
  output = {
@@ -95,6 +127,8 @@ def main():
95
127
  "cross_session_patterns": [],
96
128
  "morning_agenda": [],
97
129
  "context_packets": [],
130
+ "skills": [],
131
+ "skill_evolution_candidates": [],
98
132
  "actions": [],
99
133
  "summary": f"No significant findings for {target_date}."
100
134
  }
@@ -108,9 +142,11 @@ def main():
108
142
  prompt_template = PROMPT_FILE.read_text()
109
143
  prompt = prompt_template.replace("{{EXTRACTIONS_FILE}}", str(extractions_file))
110
144
  prompt = prompt.replace("{{CONTEXT_FILE}}", str(context_file))
145
+ prompt = prompt.replace("{{SKILL_RUNTIME_FILE}}", str(runtime_candidates_file))
111
146
 
112
147
  claude_bin = find_claude_cli()
113
148
  print(f"[synthesize] Phase 3: Synthesizing {total_findings} findings from {target_date}")
149
+ print(f"[synthesize] Skill runtime candidates: {runtime_candidate_count}")
114
150
  print(f"[synthesize] Claude CLI: {claude_bin}")
115
151
 
116
152
  try:
@@ -0,0 +1,29 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # NEXO Dashboard — Web UI at localhost:6174
4
+ # Schedule: keepAlive (persistent daemon, auto-restart on crash)
5
+ # ============================================================================
6
+ set -uo pipefail
7
+
8
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
+ NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"
10
+
11
+ # Find Python
12
+ if [ -x "$NEXO_HOME/.venv/bin/python3" ]; then
13
+ PYTHON="$NEXO_HOME/.venv/bin/python3"
14
+ else
15
+ PYTHON="python3"
16
+ fi
17
+
18
+ # Dashboard module location: prefer NEXO_CODE (repo), fallback NEXO_HOME (installed)
19
+ if [ -f "$NEXO_CODE/dashboard/app.py" ]; then
20
+ DASH_DIR="$NEXO_CODE"
21
+ elif [ -f "$NEXO_HOME/dashboard/app.py" ]; then
22
+ DASH_DIR="$NEXO_HOME"
23
+ else
24
+ echo "Dashboard not found" >&2
25
+ exit 1
26
+ fi
27
+
28
+ cd "$DASH_DIR"
29
+ exec "$PYTHON" -m dashboard.app --no-browser --port 6174