nexo-brain 2.3.2 → 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 (83) hide show
  1. package/README.md +77 -8
  2. package/bin/nexo-brain.js +230 -22
  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 +709 -37
  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 -652
  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 +384 -572
  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 -336
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +498 -652
  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 -171
  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 +25 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +983 -252
  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/capture-tool-logs.sh +18 -4
  54. package/src/hooks/post-compact.sh +5 -1
  55. package/src/hooks/pre-compact.sh +1 -1
  56. package/src/plugin_loader.py +14 -0
  57. package/src/plugins/doctor.py +36 -0
  58. package/src/plugins/evolution.py +2 -1
  59. package/src/plugins/skills.py +135 -175
  60. package/src/requirements.txt +1 -0
  61. package/src/script_registry.py +322 -0
  62. package/src/scripts/deep-sleep/apply_findings.py +63 -33
  63. package/src/scripts/deep-sleep/collect.py +38 -9
  64. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  65. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  66. package/src/scripts/deep-sleep/synthesize.py +37 -1
  67. package/src/scripts/nexo-dashboard.sh +29 -0
  68. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  69. package/src/scripts/nexo-evolution-run.py +2 -1
  70. package/src/scripts/nexo-learning-housekeep.py +1 -1
  71. package/src/scripts/nexo-watchdog.sh +1 -1
  72. package/src/server.py +9 -5
  73. package/src/skills/run-runtime-doctor/guide.md +12 -0
  74. package/src/skills/run-runtime-doctor/script.py +21 -0
  75. package/src/skills/run-runtime-doctor/skill.json +25 -0
  76. package/src/skills_runtime.py +347 -0
  77. package/src/tools_menu.py +3 -2
  78. package/src/tools_sessions.py +126 -0
  79. package/src/user_context.py +46 -0
  80. package/templates/nexo_helper.py +45 -0
  81. package/templates/script-template.py +44 -0
  82. package/templates/skill-script-template.py +39 -0
  83. 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,47 +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
- conn.execute(
271
- """INSERT INTO skills
272
- (id, name, description, level, trust_score, tags, trigger_patterns,
273
- source_sessions, linked_learnings, created_at, updated_at)
274
- VALUES (?, ?, ?, 'draft', 50, ?, ?, ?, '[]', ?, ?)""",
275
- (skill_id, name, description, tags, trigger_patterns, source_sessions, 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
+ }
276
286
  )
277
- conn.commit()
278
- conn.close()
279
- 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", "")}
280
290
  except Exception as e:
281
291
  return {"success": False, "error": str(e)}
282
292
 
@@ -688,6 +698,26 @@ def main():
688
698
  stats["errors"] += 1
689
699
  print(f" Skill error: {result.get('error', 'unknown')}", file=sys.stderr)
690
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
+
691
721
  # Create followups for abandoned projects
692
722
  abandoned_results = create_abandoned_followups(synthesis)
693
723
  for r in abandoned_results:
@@ -12,6 +12,7 @@ Environment variables:
12
12
  """
13
13
  import json
14
14
  import os
15
+ import re
15
16
  import sqlite3
16
17
  import sys
17
18
  from datetime import datetime, timedelta
@@ -25,6 +26,32 @@ COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
25
26
 
26
27
  MIN_USER_MESSAGES = 3 # Skip trivial sessions
27
28
 
29
+ # Patterns that indicate sensitive data (passwords, tokens, API keys, etc.)
30
+ _SENSITIVE_PATTERNS = re.compile(
31
+ r'(?:'
32
+ r'sk-ant-[A-Za-z0-9_-]+' # Anthropic API keys
33
+ r'|shpat_[A-Fa-f0-9]+' # Shopify admin tokens
34
+ r'|shpss_[A-Fa-f0-9]+' # Shopify shared secret
35
+ r'|sk-[A-Za-z0-9]{20,}' # OpenAI-style keys
36
+ r'|ghp_[A-Za-z0-9]{36,}' # GitHub PATs
37
+ r'|gho_[A-Za-z0-9]{36,}' # GitHub OAuth tokens
38
+ r'|AIza[A-Za-z0-9_-]{35}' # Google API keys
39
+ r'|ya29\.[A-Za-z0-9_-]+' # Google OAuth tokens
40
+ r'|xox[bpsa]-[A-Za-z0-9-]+' # Slack tokens
41
+ r'|EAAG[A-Za-z0-9]+' # Meta/Facebook tokens
42
+ r'|[Pp]assword\s*[:=]\s*\S+' # password: value or password=value
43
+ r'|[Ss]ecret\s*[:=]\s*\S+' # secret: value
44
+ r'|[Tt]oken\s*[:=]\s*\S+' # token: value
45
+ r'|[Aa]pi[_-]?[Kk]ey\s*[:=]\s*\S+' # api_key: value
46
+ r')'
47
+ )
48
+
49
+
50
+ def _redact_sensitive(text: str) -> str:
51
+ """Replace sensitive patterns in text with [REDACTED]."""
52
+ return _SENSITIVE_PATTERNS.sub('[REDACTED]', text)
53
+
54
+
28
55
  # ── Transcript collection (kept from collect_transcripts.py) ──────────────
29
56
 
30
57
 
@@ -67,7 +94,7 @@ def extract_session(jsonl_path: Path) -> dict | None:
67
94
  messages.append({
68
95
  "role": "user",
69
96
  "index": line_no,
70
- "text": content[:5000],
97
+ "text": _redact_sensitive(content[:5000]),
71
98
  "uuid": d.get("uuid", "")
72
99
  })
73
100
  user_msg_count += 1
@@ -83,16 +110,18 @@ def extract_session(jsonl_path: Path) -> dict | None:
83
110
  text_parts.append(block.get("text", ""))
84
111
  elif block.get("type") == "tool_use":
85
112
  tool_input = block.get("input", {})
113
+ raw_file = (
114
+ tool_input.get("file_path", "")
115
+ or str(tool_input.get("command", ""))[:100]
116
+ ) if isinstance(tool_input, dict) else ""
86
117
  tool_uses.append({
87
118
  "tool": block.get("name", ""),
88
119
  "input_keys": list(tool_input.keys()) if isinstance(tool_input, dict) else [],
89
- "file": (
90
- tool_input.get("file_path", "")
91
- or str(tool_input.get("command", ""))[:100]
92
- ) if isinstance(tool_input, dict) else ""
120
+ "file": _redact_sensitive(raw_file)
93
121
  })
94
122
  if text_parts:
95
123
  combined = "\n".join(text_parts)[:5000]
124
+ combined = _redact_sensitive(combined)
96
125
  messages.append({
97
126
  "role": "assistant",
98
127
  "index": line_no,
@@ -332,12 +361,12 @@ def format_transcripts(sessions: list[dict]) -> str:
332
361
  role = "USER" if msg["role"] == "user" else "AGENT"
333
362
  idx = msg.get("index", "?")
334
363
  lines.append(f"\n[{role} @{idx}]")
335
- lines.append(msg["text"])
364
+ lines.append(_redact_sensitive(msg["text"]))
336
365
 
337
366
  if session["tool_uses"]:
338
367
  lines.append(f"\n -- Tool usage log --")
339
368
  for tu in session["tool_uses"]:
340
- file_info = f" [{tu['file'][:80]}]" if tu.get("file") else ""
369
+ file_info = f" [{_redact_sensitive(tu['file'][:80])}]" if tu.get("file") else ""
341
370
  lines.append(f" - {tu['tool']}{file_info}")
342
371
 
343
372
  return "\n".join(lines)
@@ -447,12 +476,12 @@ def main():
447
476
  role = "USER" if msg["role"] == "user" else "AGENT"
448
477
  idx = msg.get("index", "?")
449
478
  lines.append(f"\n[{role} @{idx}]")
450
- lines.append(msg["text"])
479
+ lines.append(_redact_sensitive(msg["text"]))
451
480
 
452
481
  if session["tool_uses"]:
453
482
  lines.append(f"\n -- Tool usage log --")
454
483
  for tu in session["tool_uses"]:
455
- file_info = f" [{tu['file'][:80]}]" if tu.get("file") else ""
484
+ file_info = f" [{_redact_sensitive(tu['file'][:80])}]" if tu.get("file") else ""
456
485
  lines.append(f" - {tu['tool']}{file_info}")
457
486
 
458
487
  session_text = "\n".join(lines)
@@ -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",