nexo-brain 2.3.1 → 2.4.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.
@@ -13,19 +13,71 @@ from pathlib import Path
13
13
  _THIS_DIR = Path(__file__).resolve().parent
14
14
  REPO_DIR = _THIS_DIR.parent.parent
15
15
  PACKAGE_JSON = REPO_DIR / "package.json"
16
- SRC_DIR = REPO_DIR / "src"
17
16
 
18
17
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
18
  DATA_DIR = NEXO_HOME / "data"
20
19
  BACKUP_BASE = NEXO_HOME / "backups"
21
20
 
21
+ # In packaged installs, update.py lives at ~/.nexo/plugins/update.py
22
+ # so REPO_DIR would be ~/ (wrong). Detect this and fix paths.
23
+ _PACKAGED_INSTALL = not (REPO_DIR / ".git").exists() and not (REPO_DIR / ".git").is_file()
24
+
25
+ if _PACKAGED_INSTALL:
26
+ # In packaged mode, core .py files live directly in NEXO_HOME
27
+ SRC_DIR = NEXO_HOME
28
+ else:
29
+ SRC_DIR = REPO_DIR / "src"
30
+
31
+
32
+ def _find_npm_pkg_src() -> Path | None:
33
+ """Locate the nexo-brain npm package's src/ directory for requirements.txt."""
34
+ try:
35
+ result = subprocess.run(
36
+ ["npm", "root", "-g"],
37
+ capture_output=True, text=True, timeout=10,
38
+ )
39
+ if result.returncode == 0:
40
+ npm_src = Path(result.stdout.strip()) / "nexo-brain" / "src"
41
+ if npm_src.is_dir():
42
+ return npm_src
43
+ except Exception:
44
+ pass
45
+ return None
46
+
47
+ def _is_git_repo() -> bool:
48
+ """Check if REPO_DIR is a valid git repository."""
49
+ return (REPO_DIR / ".git").exists() or (REPO_DIR / ".git").is_file()
50
+
51
+
52
+ def _refresh_installed_manifest():
53
+ """Copy source crons/ to NEXO_HOME/crons/ so catchup & watchdog stay current."""
54
+ try:
55
+ src_crons = SRC_DIR / "crons"
56
+ dst_crons = NEXO_HOME / "crons"
57
+ if src_crons.exists():
58
+ dst_crons.mkdir(parents=True, exist_ok=True)
59
+ for f in src_crons.iterdir():
60
+ if f.is_file():
61
+ shutil.copy2(str(f), str(dst_crons / f.name))
62
+ except Exception:
63
+ pass
64
+
22
65
 
23
66
  def _read_version() -> str:
24
- """Read version from package.json."""
67
+ """Read version from package.json or NEXO_HOME/version.json (packaged installs)."""
25
68
  try:
26
- return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
69
+ if PACKAGE_JSON.exists():
70
+ return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
27
71
  except Exception:
28
- return "unknown"
72
+ pass
73
+ # Packaged installs don't ship package.json — check version.json in NEXO_HOME
74
+ try:
75
+ version_file = NEXO_HOME / "version.json"
76
+ if version_file.exists():
77
+ return json.loads(version_file.read_text()).get("version", "unknown")
78
+ except Exception:
79
+ pass
80
+ return "unknown"
29
81
 
30
82
 
31
83
  def _git(*args, cwd=None) -> tuple[int, str, str]:
@@ -40,13 +92,28 @@ def _git(*args, cwd=None) -> tuple[int, str, str]:
40
92
  return result.returncode, result.stdout.strip(), result.stderr.strip()
41
93
 
42
94
 
95
+ def _requirements_hash() -> str:
96
+ """Return a content hash of requirements.txt, or empty string if missing."""
97
+ import hashlib
98
+ req_file = SRC_DIR / "requirements.txt"
99
+ if not req_file.exists() and _PACKAGED_INSTALL:
100
+ npm_src = _find_npm_pkg_src()
101
+ if npm_src:
102
+ req_file = npm_src / "requirements.txt"
103
+ if req_file.exists():
104
+ return hashlib.sha256(req_file.read_bytes()).hexdigest()
105
+ return ""
106
+
107
+
43
108
  def _check_dirty() -> str | None:
44
- """Return error message if src/ has uncommitted changes, else None."""
45
- rc, out, _ = _git("status", "--porcelain", "--", "src/")
109
+ """Return error message if worktree has uncommitted changes, else None."""
110
+ if not _is_git_repo():
111
+ return None # Not a git repo, skip dirty check
112
+ rc, out, _ = _git("status", "--porcelain")
46
113
  if rc != 0:
47
114
  return "Failed to check git status."
48
115
  if out:
49
- return f"Uncommitted changes in src/:\n{out}\nCommit or stash before updating."
116
+ return f"Uncommitted changes:\n{out}\nCommit or stash before updating."
50
117
  return None
51
118
 
52
119
 
@@ -102,12 +169,51 @@ def _restore_databases(backup_dir: str):
102
169
  break
103
170
 
104
171
 
172
+ def _reinstall_pip_deps() -> str | None:
173
+ """Reinstall Python dependencies from requirements.txt into the managed venv."""
174
+ req_file = SRC_DIR / "requirements.txt"
175
+ if not req_file.exists() and _PACKAGED_INSTALL:
176
+ # In packaged mode, requirements.txt lives in the npm package's src/ dir
177
+ npm_src = _find_npm_pkg_src()
178
+ if npm_src:
179
+ req_file = npm_src / "requirements.txt"
180
+ if not req_file.exists():
181
+ return None # No requirements file, skip
182
+ venv_pip = NEXO_HOME / ".venv" / "bin" / "pip"
183
+ if not venv_pip.exists():
184
+ venv_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
185
+ if not venv_pip.exists():
186
+ # No venv, try system pip with --break-system-packages
187
+ try:
188
+ result = subprocess.run(
189
+ [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
190
+ capture_output=True, text=True, timeout=120,
191
+ )
192
+ if result.returncode != 0:
193
+ return f"pip install failed: {result.stderr or result.stdout}"
194
+ except Exception as e:
195
+ return f"pip install error: {e}"
196
+ return None
197
+ try:
198
+ result = subprocess.run(
199
+ [str(venv_pip), "install", "--quiet", "-r", str(req_file)],
200
+ capture_output=True, text=True, timeout=120,
201
+ )
202
+ if result.returncode != 0:
203
+ return f"pip install failed: {result.stderr or result.stdout}"
204
+ except Exception as e:
205
+ return f"pip install error: {e}"
206
+ return None
207
+
208
+
105
209
  def _run_migrations() -> str | None:
106
210
  """Run init_db() to apply pending migrations. Returns error or None."""
211
+ # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
212
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
107
213
  try:
108
214
  result = subprocess.run(
109
215
  [sys.executable, "-c", "import db; db.init_db()"],
110
- cwd=str(SRC_DIR),
216
+ cwd=cwd,
111
217
  capture_output=True,
112
218
  text=True,
113
219
  timeout=30,
@@ -121,10 +227,12 @@ def _run_migrations() -> str | None:
121
227
 
122
228
  def _verify_import() -> str | None:
123
229
  """Verify server.py can be imported successfully."""
230
+ # In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
231
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
124
232
  try:
125
233
  result = subprocess.run(
126
234
  [sys.executable, "-c", "import server"],
127
- cwd=str(SRC_DIR),
235
+ cwd=cwd,
128
236
  capture_output=True,
129
237
  text=True,
130
238
  timeout=15,
@@ -136,27 +244,235 @@ def _verify_import() -> str | None:
136
244
  return None
137
245
 
138
246
 
247
+ def _sync_hooks_to_home():
248
+ """Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after update."""
249
+ import shutil
250
+ hooks_src = SRC_DIR / "hooks"
251
+ hooks_dest = NEXO_HOME / "hooks"
252
+ if not hooks_src.is_dir():
253
+ return
254
+ hooks_dest.mkdir(parents=True, exist_ok=True)
255
+ synced = 0
256
+ for f in hooks_src.iterdir():
257
+ if f.is_file() and f.suffix == ".sh":
258
+ dest = hooks_dest / f.name
259
+ shutil.copy2(str(f), str(dest))
260
+ os.chmod(str(dest), 0o755)
261
+ synced += 1
262
+ if synced:
263
+ print(f"[NEXO update] Synced {synced} hook(s) to {hooks_dest}", file=sys.stderr)
264
+
265
+
266
+ def _backup_code_tree() -> tuple[str | None, str | None]:
267
+ """Snapshot NEXO_HOME code dirs before npm update. Returns (backup_dir, error)."""
268
+ timestamp = time.strftime("%Y-%m-%d-%H%M%S")
269
+ backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
270
+ # Directories and flat files that postinstall copies into NEXO_HOME
271
+ code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
272
+ code_files_glob = ["*.py", "requirements.txt"]
273
+ try:
274
+ backup_dir.mkdir(parents=True, exist_ok=True)
275
+ # Backup directories
276
+ for d in code_dirs:
277
+ src = NEXO_HOME / d
278
+ if src.is_dir():
279
+ shutil.copytree(src, backup_dir / d, dirs_exist_ok=True)
280
+ # Backup flat code files in NEXO_HOME root
281
+ for pattern in code_files_glob:
282
+ for f in NEXO_HOME.glob(pattern):
283
+ if f.is_file():
284
+ shutil.copy2(f, backup_dir / f.name)
285
+ # Backup version.json
286
+ vf = NEXO_HOME / "version.json"
287
+ if vf.is_file():
288
+ shutil.copy2(vf, backup_dir / "version.json")
289
+ except Exception as e:
290
+ return None, f"Code tree backup failed: {e}"
291
+ return str(backup_dir), None
292
+
293
+
294
+ def _restore_code_tree(backup_dir: str) -> str | None:
295
+ """Restore NEXO_HOME code dirs from a backup snapshot. Returns error or None."""
296
+ bdir = Path(backup_dir)
297
+ if not bdir.is_dir():
298
+ return f"Code tree backup dir not found: {backup_dir}"
299
+ try:
300
+ for item in bdir.iterdir():
301
+ dest = NEXO_HOME / item.name
302
+ if item.is_dir():
303
+ if dest.is_dir():
304
+ shutil.rmtree(dest)
305
+ shutil.copytree(item, dest)
306
+ elif item.is_file():
307
+ shutil.copy2(item, dest)
308
+ except Exception as e:
309
+ return f"Code tree restore failed: {e}"
310
+ return None
311
+
312
+
313
+ def _rollback_npm_package(target_version: str) -> str | None:
314
+ """Rollback nexo-brain npm package to a specific version.
315
+
316
+ Uses NEXO_SKIP_POSTINSTALL because we restore the code tree
317
+ from our own pre-update backup — no need for postinstall migration.
318
+ """
319
+ try:
320
+ result = subprocess.run(
321
+ ["npm", "install", "-g", f"nexo-brain@{target_version}"],
322
+ capture_output=True, text=True, timeout=120,
323
+ env={**os.environ, "NEXO_SKIP_POSTINSTALL": "1", "NEXO_HOME": str(NEXO_HOME)},
324
+ )
325
+ if result.returncode != 0:
326
+ return f"npm rollback failed: {result.stderr or result.stdout}"
327
+ except Exception as e:
328
+ return f"npm rollback error: {e}"
329
+ return None
330
+
331
+
332
+ def _handle_packaged_update() -> str:
333
+ """Update a packaged (npm) install — no git repo available."""
334
+ old_version = _read_version()
335
+
336
+ # 1. Backup databases BEFORE any changes
337
+ backup_dir, backup_err = _backup_databases()
338
+ if backup_err:
339
+ return f"ABORTED at backup: {backup_err}"
340
+
341
+ # 2. Backup NEXO_HOME code tree BEFORE npm update
342
+ # postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
343
+ # so we need a full snapshot to restore on failure.
344
+ code_backup_dir, code_err = _backup_code_tree()
345
+ if code_err:
346
+ return f"ABORTED at code tree backup: {code_err}"
347
+
348
+ # 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
349
+ try:
350
+ result = subprocess.run(
351
+ ["npm", "update", "-g", "nexo-brain"],
352
+ capture_output=True, text=True, timeout=120,
353
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
354
+ )
355
+ if result.returncode != 0:
356
+ # npm failed (including postinstall failures) — full rollback
357
+ if backup_dir:
358
+ _restore_databases(backup_dir)
359
+ if code_backup_dir:
360
+ _restore_code_tree(code_backup_dir)
361
+ # Reinstall pip deps from restored old requirements.txt
362
+ _reinstall_pip_deps()
363
+ rollback_err = _rollback_npm_package(old_version)
364
+ msg = f"ABORTED: npm update failed: {result.stderr or result.stdout}"
365
+ if rollback_err:
366
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
367
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
368
+ return msg
369
+ except FileNotFoundError:
370
+ return "ABORTED: npm not found. Install Node.js to update packaged installs."
371
+ except Exception as e:
372
+ if backup_dir:
373
+ _restore_databases(backup_dir)
374
+ if code_backup_dir:
375
+ _restore_code_tree(code_backup_dir)
376
+ # Reinstall pip deps from restored old requirements.txt
377
+ _reinstall_pip_deps()
378
+ rollback_err = _rollback_npm_package(old_version)
379
+ msg = f"ABORTED: npm update error: {e}"
380
+ if rollback_err:
381
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
382
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
383
+ return msg
384
+
385
+ new_version = _read_version()
386
+ if old_version == new_version:
387
+ return f"Already up to date (v{old_version}). No changes."
388
+
389
+ # 4. Post-npm verification steps
390
+ errors = []
391
+
392
+ # Reinstall pip deps for new version
393
+ pip_err = _reinstall_pip_deps()
394
+ if pip_err:
395
+ errors.append(f"pip deps: {pip_err}")
396
+
397
+ # Run migrations
398
+ mig_err = _run_migrations()
399
+ if mig_err:
400
+ errors.append(f"migrations: {mig_err}")
401
+
402
+ # Verify server can still import
403
+ verify_err = _verify_import()
404
+ if verify_err:
405
+ errors.append(f"verification: {verify_err}")
406
+
407
+ if errors:
408
+ # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
409
+ if code_backup_dir:
410
+ tree_err = _restore_code_tree(code_backup_dir)
411
+ else:
412
+ tree_err = "no code tree backup available"
413
+ if backup_dir:
414
+ _restore_databases(backup_dir)
415
+ # Reinstall pip deps from the restored (old) requirements.txt
416
+ # so the venv matches the rolled-back code tree
417
+ pip_rollback_err = _reinstall_pip_deps() if not tree_err else None
418
+ rollback_err = _rollback_npm_package(old_version)
419
+ lines = [f"UPDATE FAILED (packaged install, v{old_version} -> v{new_version})"]
420
+ for err in errors:
421
+ lines.append(f" ERROR: {err}")
422
+ lines.append(f" Databases restored from: {backup_dir}")
423
+ if tree_err:
424
+ lines.append(f" WARNING: code tree restore failed: {tree_err}")
425
+ else:
426
+ lines.append(f" Code tree restored from: {code_backup_dir}")
427
+ if pip_rollback_err:
428
+ lines.append(f" WARNING: pip deps rollback failed: {pip_rollback_err}")
429
+ elif not tree_err:
430
+ lines.append(" Python deps: reinstalled from old requirements.txt")
431
+ if rollback_err:
432
+ lines.append(f" WARNING: npm rollback failed: {rollback_err}")
433
+ lines.append(f" Manual rollback: npm install -g nexo-brain@{old_version}")
434
+ else:
435
+ lines.append(f" npm package rolled back to v{old_version}")
436
+ lines.append("")
437
+ lines.append("Fix the errors above, then run nexo_update again.")
438
+ return "\n".join(lines)
439
+
440
+ lines = ["UPDATE SUCCESSFUL (packaged install)"]
441
+ lines.append(f" Version: {old_version} -> {new_version}")
442
+ lines.append(f" Backup: {backup_dir}")
443
+ lines.append("")
444
+ lines.append("MCP server restart needed to load new code.")
445
+ return "\n".join(lines)
446
+
447
+
139
448
  def handle_update(remote: str = "origin", branch: str = "main") -> str:
140
449
  """Pull latest NEXO code, backup databases, run migrations, and verify.
141
450
 
142
- Full update flow:
143
- 1. Check for uncommitted changes in src/
451
+ Supports both git checkouts and packaged (npm) installs.
452
+
453
+ Full update flow (git):
454
+ 1. Check for uncommitted changes in entire worktree
144
455
  2. Backup all .db files
145
456
  3. git pull
146
- 4. Run migrations if version changed
147
- 5. Verify server.py imports
148
- 6. Rollback on failure
457
+ 4. Reinstall Python dependencies if version changed
458
+ 5. Run migrations if version changed
459
+ 6. Verify server.py imports
460
+ 7. Rollback on failure (git reset --hard to saved commit)
149
461
 
150
462
  Args:
151
463
  remote: Git remote name (default: origin)
152
464
  branch: Git branch to pull (default: main)
153
465
  """
466
+ # Packaged install — no git repo
467
+ if not _is_git_repo():
468
+ return _handle_packaged_update()
469
+
154
470
  steps_done = []
155
471
  old_commit = None
156
472
  backup_dir = None
157
473
 
158
474
  try:
159
- # Step 1: Check dirty
475
+ # Step 1: Check dirty (full worktree)
160
476
  dirty_err = _check_dirty()
161
477
  if dirty_err:
162
478
  return f"ABORTED: {dirty_err}"
@@ -164,6 +480,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
164
480
 
165
481
  # Record current state
166
482
  old_version = _read_version()
483
+ old_req_hash = _requirements_hash()
167
484
  rc, old_commit, _ = _git("rev-parse", "HEAD")
168
485
  if rc != 0:
169
486
  return "ABORTED: Not a git repository or git not available."
@@ -180,39 +497,59 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
180
497
  return f"ABORTED at git pull: {pull_err or pull_out}"
181
498
  steps_done.append("git-pull")
182
499
 
183
- # Step 4: Check version change
500
+ # Step 4: Check version and dependency changes
184
501
  new_version = _read_version()
185
502
  version_changed = old_version != new_version
503
+ new_req_hash = _requirements_hash()
504
+ deps_changed = old_req_hash != new_req_hash
186
505
 
187
- # Step 5: Run migrations if version changed
506
+ # Step 5: Reinstall pip dependencies if requirements.txt changed
507
+ if deps_changed or version_changed:
508
+ pip_err = _reinstall_pip_deps()
509
+ if pip_err:
510
+ raise RuntimeError(f"Pip install failed: {pip_err}")
511
+ steps_done.append("pip-deps")
512
+
513
+ # Step 6: Run migrations if version changed
188
514
  if version_changed:
189
515
  mig_err = _run_migrations()
190
516
  if mig_err:
191
517
  raise RuntimeError(f"Migration failed: {mig_err}")
192
518
  steps_done.append("migrations")
193
519
 
194
- # Step 6: Verify import
520
+ # Step 7: Verify import
195
521
  verify_err = _verify_import()
196
522
  if verify_err:
197
523
  raise RuntimeError(f"Verification failed: {verify_err}")
198
524
  steps_done.append("verify")
199
525
 
200
- # Step 7: Sync crons with manifest
526
+ # Step 8: Sync crons with manifest
201
527
  cron_sync_result = ""
202
528
  try:
203
- cron_sync_path = NEXO_CODE / "crons" / "sync.py"
529
+ cron_sync_path = SRC_DIR / "crons" / "sync.py"
204
530
  if cron_sync_path.exists():
205
- import subprocess as _sp
206
- r = _sp.run(
531
+ r = subprocess.run(
207
532
  [sys.executable, str(cron_sync_path)],
208
533
  capture_output=True, text=True, timeout=30,
209
- env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
534
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(SRC_DIR)},
210
535
  )
211
536
  cron_sync_result = r.stdout.strip()
212
- steps_done.append("cron-sync")
537
+ if r.returncode == 0:
538
+ steps_done.append("cron-sync")
539
+ # Refresh installed manifest only after successful sync
540
+ _refresh_installed_manifest()
541
+ else:
542
+ cron_sync_result = f"Cron sync failed (exit {r.returncode}): {r.stderr or r.stdout}"
213
543
  except Exception as e:
214
544
  cron_sync_result = f"Cron sync warning: {e}"
215
545
 
546
+ # Step 9: Sync hooks to NEXO_HOME
547
+ try:
548
+ _sync_hooks_to_home()
549
+ steps_done.append("hook-sync")
550
+ except Exception as e:
551
+ pass # Non-critical, log in function
552
+
216
553
  # Build result
217
554
  if pull_out == "Already up to date.":
218
555
  return f"Already up to date (v{old_version}). No changes pulled."
@@ -224,22 +561,35 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
224
561
  lines.append(f" Version: {old_version} (unchanged)")
225
562
  lines.append(f" Branch: {remote}/{branch}")
226
563
  lines.append(f" Backup: {backup_dir}")
564
+ if "pip-deps" in steps_done:
565
+ lines.append(" Python deps: reinstalled")
227
566
  if version_changed:
228
567
  lines.append(" Migrations: applied")
229
568
  if "cron-sync" in steps_done:
230
569
  lines.append(" Crons: synced with manifest")
570
+ if "hook-sync" in steps_done:
571
+ lines.append(" Hooks: synced to NEXO_HOME")
231
572
  lines.append("")
232
573
  lines.append("MCP server restart needed to load new code.")
233
574
  return "\n".join(lines)
234
575
 
235
576
  except Exception as e:
236
- # Rollback
577
+ # Rollback — use git checkout to saved commit (safer than reset --hard)
237
578
  rollback_lines = [f"UPDATE FAILED: {e}", "", "Rolling back..."]
238
579
 
239
580
  if old_commit and "git-pull" in steps_done:
581
+ # Full rollback: reset HEAD + index + worktree to old commit
240
582
  rc, _, err = _git("reset", "--hard", old_commit)
241
583
  if rc == 0:
242
- rollback_lines.append(f" Git: reset to {old_commit[:8]}")
584
+ rollback_lines.append(f" Git: restored files to {old_commit[:8]}")
585
+ # Reinstall pip deps from the restored old requirements.txt
586
+ # so the venv matches the rolled-back code
587
+ if "pip-deps" in steps_done:
588
+ pip_rb_err = _reinstall_pip_deps()
589
+ if pip_rb_err:
590
+ rollback_lines.append(f" WARNING: pip deps rollback failed: {pip_rb_err}")
591
+ else:
592
+ rollback_lines.append(" Python deps: reinstalled from old requirements.txt")
243
593
  else:
244
594
  rollback_lines.append(f" Git rollback FAILED: {err}")
245
595
 
@@ -267,12 +267,27 @@ def create_skill(skill_data: dict) -> dict:
267
267
  return {"success": False, "error": f"Skill {skill_id} already exists", "id": skill_id}
268
268
 
269
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
+
270
284
  conn.execute(
271
285
  """INSERT INTO skills
272
286
  (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),
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),
276
291
  )
277
292
  conn.commit()
278
293
  conn.close()
@@ -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)