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.
- package/README.md +77 -8
- package/bin/nexo-brain.js +230 -22
- package/bin/nexo.js +55 -0
- package/community/skills/.gitkeep +1 -0
- package/package.json +5 -2
- package/src/auto_update.py +158 -8
- package/src/cli.py +605 -0
- package/src/cognitive/_ingest.py +1 -1
- package/src/cognitive/_memory.py +4 -4
- package/src/crons/manifest.json +8 -0
- package/src/dashboard/app.py +709 -37
- package/src/dashboard/templates/adaptive.html +112 -218
- package/src/dashboard/templates/artifacts.html +133 -0
- package/src/dashboard/templates/backups.html +136 -0
- package/src/dashboard/templates/base.html +413 -0
- package/src/dashboard/templates/calendar.html +523 -652
- package/src/dashboard/templates/chat.html +356 -0
- package/src/dashboard/templates/claims.html +259 -0
- package/src/dashboard/templates/cortex.html +262 -0
- package/src/dashboard/templates/credentials.html +128 -0
- package/src/dashboard/templates/crons.html +370 -0
- package/src/dashboard/templates/dashboard.html +384 -572
- package/src/dashboard/templates/dreams.html +252 -0
- package/src/dashboard/templates/email.html +160 -0
- package/src/dashboard/templates/evolution.html +189 -0
- package/src/dashboard/templates/feed.html +249 -0
- package/src/dashboard/templates/followup_health.html +170 -0
- package/src/dashboard/templates/graph.html +191 -269
- package/src/dashboard/templates/guard.html +259 -0
- package/src/dashboard/templates/inbox.html +220 -336
- package/src/dashboard/templates/memory.html +317 -197
- package/src/dashboard/templates/operations.html +498 -652
- package/src/dashboard/templates/plugins.html +185 -0
- package/src/dashboard/templates/rules.html +246 -0
- package/src/dashboard/templates/sentiment.html +247 -0
- package/src/dashboard/templates/sessions.html +215 -171
- package/src/dashboard/templates/skills.html +329 -0
- package/src/dashboard/templates/somatic.html +68 -172
- package/src/dashboard/templates/triggers.html +133 -0
- package/src/dashboard/templates/trust.html +360 -0
- package/src/db/__init__.py +5 -0
- package/src/db/_schema.py +25 -1
- package/src/db/_sessions.py +22 -0
- package/src/db/_skills.py +983 -252
- package/src/doctor/__init__.py +1 -0
- package/src/doctor/formatters.py +52 -0
- package/src/doctor/models.py +44 -0
- package/src/doctor/orchestrator.py +42 -0
- package/src/doctor/providers/__init__.py +1 -0
- package/src/doctor/providers/boot.py +206 -0
- package/src/doctor/providers/deep.py +292 -0
- package/src/doctor/providers/runtime.py +686 -0
- package/src/hooks/capture-tool-logs.sh +18 -4
- package/src/hooks/post-compact.sh +5 -1
- package/src/hooks/pre-compact.sh +1 -1
- package/src/plugin_loader.py +14 -0
- package/src/plugins/doctor.py +36 -0
- package/src/plugins/evolution.py +2 -1
- package/src/plugins/skills.py +135 -175
- package/src/requirements.txt +1 -0
- package/src/script_registry.py +322 -0
- package/src/scripts/deep-sleep/apply_findings.py +63 -33
- package/src/scripts/deep-sleep/collect.py +38 -9
- package/src/scripts/deep-sleep/extract-prompt.md +14 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
- package/src/scripts/deep-sleep/synthesize.py +37 -1
- package/src/scripts/nexo-dashboard.sh +29 -0
- package/src/scripts/nexo-day-orchestrator.sh +139 -0
- package/src/scripts/nexo-evolution-run.py +2 -1
- package/src/scripts/nexo-learning-housekeep.py +1 -1
- package/src/scripts/nexo-watchdog.sh +1 -1
- package/src/server.py +9 -5
- package/src/skills/run-runtime-doctor/guide.md +12 -0
- package/src/skills/run-runtime-doctor/script.py +21 -0
- package/src/skills/run-runtime-doctor/skill.json +25 -0
- package/src/skills_runtime.py +347 -0
- package/src/tools_menu.py +3 -2
- package/src/tools_sessions.py +126 -0
- package/src/user_context.py +46 -0
- package/templates/nexo_helper.py +45 -0
- package/templates/script-template.py +44 -0
- package/templates/skill-script-template.py +39 -0
- 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
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
return {"success": True, "id":
|
|
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",
|