nexo-brain 2.3.2 → 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.
package/README.md CHANGED
@@ -184,11 +184,11 @@ This means long sessions (8+ hours) feel like one continuous conversation instea
184
184
  "hooks": {
185
185
  "PreCompact": [{
186
186
  "matcher": "*",
187
- "hooks": [{"type": "command", "command": "bash path/to/nexo/src/hooks/pre-compact.sh", "timeout": 10}]
187
+ "hooks": [{"type": "command", "command": "bash $NEXO_HOME/hooks/pre-compact.sh", "timeout": 10}]
188
188
  }],
189
189
  "PostCompact": [{
190
190
  "matcher": "*",
191
- "hooks": [{"type": "command", "command": "bash path/to/nexo/src/hooks/post-compact.sh", "timeout": 10}]
191
+ "hooks": [{"type": "command", "command": "bash $NEXO_HOME/hooks/post-compact.sh", "timeout": 10}]
192
192
  }]
193
193
  }
194
194
  }
@@ -364,7 +364,13 @@ A web interface at `localhost:6174` with 6 interactive pages for visual insight
364
364
  | **Adaptive** | Personality signals, learned weights, and current mode |
365
365
  | **Sessions** | Active and historical sessions with timeline and diary entries |
366
366
 
367
- Built with FastAPI backend and D3.js frontend. Runs as a LaunchAgent, auto-starts with the system.
367
+ Built with FastAPI backend and D3.js frontend. Dashboard files are installed to `NEXO_HOME/dashboard/` but must be started manually:
368
+
369
+ ```bash
370
+ python3 ~/.nexo/dashboard/app.py
371
+ ```
372
+
373
+ This opens `localhost:6174` in your browser. Add `--port 8080` to change the port or `--no-browser` to skip auto-opening.
368
374
 
369
375
  ## Full Orchestration System
370
376
 
@@ -499,9 +505,9 @@ atlas
499
505
 
500
506
  Under the hood, the alias runs:
501
507
  ```bash
502
- claude --append-system-prompt "You are NEXO. Run nexo_startup immediately, load context, greet the user." "."
508
+ claude --dangerously-skip-permissions "."
503
509
  ```
504
- `--append-system-prompt` adds to the default system prompt without replacing it (preserves CLAUDE.md). The `"."` triggers the operator to start immediately.
510
+ `--dangerously-skip-permissions` launches Claude Code with tool-use permissions pre-approved so the operator can act autonomously. The `"."` triggers the operator to start immediately. Operator behavior (startup, context, greeting) is defined in `~/.claude/CLAUDE.md`.
505
511
 
506
512
  That's it. No need to run `claude` manually. Your operator will greet you immediately — adapted to the time of day, resuming from where you left off if there's a previous session. No cold starts, no waiting for your input.
507
513
 
@@ -687,7 +693,7 @@ This replaces OpenClaw's default memory system with NEXO Brain's full cognitive
687
693
 
688
694
  ### Any MCP Client
689
695
 
690
- NEXO Brain works with any application that supports the MCP protocol. Configure it as an MCP server pointing to `server.py` in the code directory, with `NEXO_HOME` env var set.
696
+ NEXO Brain works with any application that supports the MCP protocol. Configure it as an MCP server pointing to `server.py` inside `NEXO_HOME` (default `~/.nexo/server.py`), with the `NEXO_HOME` env var set to the same directory.
691
697
 
692
698
  ## Listed On
693
699
 
package/bin/nexo-brain.js CHANGED
@@ -1880,22 +1880,33 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
1880
1880
 
1881
1881
  // Detect shell and add alias
1882
1882
  const userShell = process.env.SHELL || "/bin/bash";
1883
- const rcFile = userShell.includes("zsh")
1884
- ? path.join(require("os").homedir(), ".zshrc")
1885
- : path.join(require("os").homedir(), ".bash_profile");
1883
+ const homeDir = require("os").homedir();
1884
+ const rcFiles = [];
1886
1885
 
1887
- let rcContent = "";
1888
- if (fs.existsSync(rcFile)) {
1889
- rcContent = fs.readFileSync(rcFile, "utf8");
1886
+ if (userShell.includes("zsh")) {
1887
+ rcFiles.push(path.join(homeDir, ".zshrc"));
1888
+ } else {
1889
+ // Bash: always write to .bash_profile (macOS login shells)
1890
+ rcFiles.push(path.join(homeDir, ".bash_profile"));
1891
+ // Also write to .bashrc (Linux interactive shells) — create if needed
1892
+ const bashrc = path.join(homeDir, ".bashrc");
1893
+ rcFiles.push(bashrc);
1890
1894
  }
1891
1895
 
1892
- if (!rcContent.includes(`alias ${aliasName}=`)) {
1893
- fs.appendFileSync(rcFile, `\n${aliasComment}\n${aliasLine}\n`);
1894
- log(`Added '${aliasName}' alias to ${path.basename(rcFile)}`);
1895
- log(`After setup, open a new terminal and type: ${aliasName}`);
1896
- } else {
1897
- log(`Alias '${aliasName}' already exists in ${path.basename(rcFile)}`);
1896
+ for (const rcFile of rcFiles) {
1897
+ let rcContent = "";
1898
+ if (fs.existsSync(rcFile)) {
1899
+ rcContent = fs.readFileSync(rcFile, "utf8");
1900
+ }
1901
+
1902
+ if (!rcContent.includes(`alias ${aliasName}=`)) {
1903
+ fs.appendFileSync(rcFile, `\n${aliasComment}\n${aliasLine}\n`);
1904
+ log(`Added '${aliasName}' alias to ${path.basename(rcFile)}`);
1905
+ } else {
1906
+ log(`Alias '${aliasName}' already exists in ${path.basename(rcFile)}`);
1907
+ }
1898
1908
  }
1909
+ log(`After setup, open a new terminal and type: ${aliasName}`);
1899
1910
  console.log("");
1900
1911
 
1901
1912
  // Step 9: Generate CLAUDE.md template
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — Cognitive co-operator for Claude Code. Memory, emotional intelligence, overnight learning (Deep Sleep), cron management, trust scoring, and adaptive calibration.",
6
6
  "bin": {
@@ -616,13 +616,20 @@ async def api_ops_execute(fid: str):
616
616
  if not row:
617
617
  return JSONResponse({"error": f"Followup {fid} not found"}, status_code=404)
618
618
  item = dict(row)
619
- description = item["description"].replace('"', '\\"').replace("'", "\\'")
620
619
  if platform.system() != "Darwin":
621
620
  return JSONResponse(
622
621
  {"error": "This operation requires macOS (uses osascript to open Terminal)"},
623
622
  status_code=501,
624
623
  )
625
- script = f'tell application "Terminal" to do script "claude \\"NEXO: execute followup #{fid} — {description}\\""'
624
+ # Security: avoid interpolating user-controlled data into shell commands.
625
+ # Write the followup ID to a temp file and pass a safe, fixed command to osascript.
626
+ import tempfile
627
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", prefix="nexo-followup-", delete=False)
628
+ tmp.write(fid)
629
+ tmp.close()
630
+ # The claude command reads the followup ID from the temp file — no shell interpolation of description
631
+ claude_cmd = f'claude \\"NEXO: execute followup from file $(cat {tmp.name})\\"'
632
+ script = f'tell application "Terminal" to do script "{claude_cmd}"'
626
633
  subprocess.Popen(["osascript", "-e", script])
627
634
  return {"success": True, "followup_id": fid}
628
635
 
@@ -303,7 +303,7 @@
303
303
  btn.innerHTML = `<svg class="w-3 h-3 spin" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Running`;
304
304
  btn.disabled = true;
305
305
  try {
306
- const res = await fetch(`/api/followups/${id}/execute`, { method: 'POST' });
306
+ const res = await fetch(`/api/ops/execute/${id}`, { method: 'POST' });
307
307
  const data = await res.json();
308
308
  if (res.ok) {
309
309
  btn.innerHTML = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg> Done`;
@@ -311,13 +311,15 @@
311
311
  // Reload items in background
312
312
  await loadCalendarData();
313
313
  } else {
314
- btn.innerHTML = ' Failed';
314
+ const errMsg = data.error || data.detail || 'HTTP ' + res.status;
315
+ btn.innerHTML = '✗ ' + errMsg;
316
+ btn.title = errMsg;
315
317
  btn.className = btn.className.replace('bg-violet-600 hover:bg-violet-500', 'bg-red-700');
316
- setTimeout(() => { btn.innerHTML = originalHtml; btn.disabled = false; btn.className = btn.className.replace('bg-red-700', 'bg-violet-600 hover:bg-violet-500'); }, 2000);
318
+ setTimeout(() => { btn.innerHTML = originalHtml; btn.title = ''; btn.disabled = false; btn.className = btn.className.replace('bg-red-700', 'bg-violet-600 hover:bg-violet-500'); }, 3000);
317
319
  }
318
320
  } catch(e) {
319
- btn.innerHTML = '✗ Error';
320
- setTimeout(() => { btn.innerHTML = originalHtml; btn.disabled = false; }, 2000);
321
+ btn.innerHTML = '✗ ' + e.message;
322
+ setTimeout(() => { btn.innerHTML = originalHtml; btn.disabled = false; }, 3000);
321
323
  }
322
324
  }
323
325
 
@@ -310,9 +310,16 @@
310
310
  async function fetchJSON(url) {
311
311
  try {
312
312
  const res = await fetch(url);
313
- if (!res.ok) return null;
313
+ if (!res.ok) {
314
+ let detail = `HTTP ${res.status}`;
315
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
316
+ throw new Error(detail);
317
+ }
314
318
  return await res.json();
315
- } catch { return null; }
319
+ } catch (err) {
320
+ console.error(`fetchJSON(${url}):`, err);
321
+ return null;
322
+ }
316
323
  }
317
324
 
318
325
  function getToday() {
@@ -387,7 +394,7 @@
387
394
  closeModal();
388
395
  loadDashboardData();
389
396
  } else {
390
- showToast(data.error || 'Failed to create');
397
+ showToast(data.error || data.detail || 'Create failed (HTTP ' + res.status + ')');
391
398
  }
392
399
  } catch (err) {
393
400
  showToast('Error: ' + err.message);
@@ -245,6 +245,11 @@
245
245
  async function loadMessages() {
246
246
  try {
247
247
  const res = await fetch('/api/inbox?limit=200');
248
+ if (!res.ok) {
249
+ let detail = `HTTP ${res.status}`;
250
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
251
+ throw new Error(detail);
252
+ }
248
253
  const data = await res.json();
249
254
  messages = data.notes || [];
250
255
  messages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
@@ -281,6 +286,11 @@
281
286
  headers: { 'Content-Type': 'application/json' },
282
287
  body: JSON.stringify(body)
283
288
  });
289
+ if (!res.ok) {
290
+ let detail = `HTTP ${res.status}`;
291
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
292
+ throw new Error(detail);
293
+ }
284
294
  const data = await res.json();
285
295
  textarea.value = '';
286
296
  cancelReply();
@@ -368,9 +368,16 @@
368
368
  async function fetchJSON(url) {
369
369
  try {
370
370
  const res = await fetch(url);
371
- if (!res.ok) return null;
371
+ if (!res.ok) {
372
+ let detail = `HTTP ${res.status}`;
373
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
374
+ throw new Error(detail);
375
+ }
372
376
  return await res.json();
373
- } catch { return null; }
377
+ } catch (err) {
378
+ console.error(`fetchJSON(${url}):`, err);
379
+ return null;
380
+ }
374
381
  }
375
382
 
376
383
  // -----------------------------------------------------------------------
@@ -538,56 +545,72 @@
538
545
  // Actions
539
546
  // -----------------------------------------------------------------------
540
547
  async function completeItem(id, type) {
541
- const url = type === 'reminder' ? '/api/reminders/' + id : '/api/followups/' + id;
542
- const res = await fetch(url, {
543
- method: 'PUT',
544
- headers: { 'Content-Type': 'application/json' },
545
- body: JSON.stringify({ status: 'COMPLETED' })
546
- });
547
- const data = await res.json();
548
- if (data.success) {
549
- showToast('Completed ' + id);
550
- loadOpsData();
551
- } else {
552
- showToast(data.error || 'Failed', 'error');
548
+ try {
549
+ const url = type === 'reminder' ? '/api/reminders/' + id : '/api/followups/' + id;
550
+ const res = await fetch(url, {
551
+ method: 'PUT',
552
+ headers: { 'Content-Type': 'application/json' },
553
+ body: JSON.stringify({ status: 'COMPLETED' })
554
+ });
555
+ const data = await res.json();
556
+ if (data.success) {
557
+ showToast('Completed ' + id);
558
+ loadOpsData();
559
+ } else {
560
+ showToast(data.error || data.detail || 'Complete failed (HTTP ' + res.status + ')', 'error');
561
+ }
562
+ } catch (err) {
563
+ showToast('Complete error: ' + err.message, 'error');
553
564
  }
554
565
  }
555
566
 
556
567
  async function moveItem(id, direction) {
557
- const res = await fetch('/api/ops/move', {
558
- method: 'POST',
559
- headers: { 'Content-Type': 'application/json' },
560
- body: JSON.stringify({ id, direction })
561
- });
562
- const data = await res.json();
563
- if (data.success) {
564
- showToast('Moved ' + id + ' to ' + (direction === 'to_followup' ? 'NEXO' : 'User'));
565
- loadOpsData();
566
- } else {
567
- showToast(data.error || 'Failed to move', 'error');
568
+ try {
569
+ const res = await fetch('/api/ops/move', {
570
+ method: 'POST',
571
+ headers: { 'Content-Type': 'application/json' },
572
+ body: JSON.stringify({ id, direction })
573
+ });
574
+ const data = await res.json();
575
+ if (data.success) {
576
+ showToast('Moved ' + id + ' to ' + (direction === 'to_followup' ? 'NEXO' : 'User'));
577
+ loadOpsData();
578
+ } else {
579
+ showToast(data.error || data.detail || 'Move failed (HTTP ' + res.status + ')', 'error');
580
+ }
581
+ } catch (err) {
582
+ showToast('Move error: ' + err.message, 'error');
568
583
  }
569
584
  }
570
585
 
571
586
  async function executeItem(id) {
572
- const res = await fetch('/api/ops/execute/' + id, { method: 'POST' });
573
- const data = await res.json();
574
- if (data.success) {
575
- showToast('Executing ' + id, 'info');
576
- } else {
577
- showToast(data.error || 'Failed', 'error');
587
+ try {
588
+ const res = await fetch('/api/ops/execute/' + id, { method: 'POST' });
589
+ const data = await res.json();
590
+ if (data.success) {
591
+ showToast('Executing ' + id, 'info');
592
+ } else {
593
+ showToast(data.error || data.detail || 'Execute failed (HTTP ' + res.status + ')', 'error');
594
+ }
595
+ } catch (err) {
596
+ showToast('Execute error: ' + err.message, 'error');
578
597
  }
579
598
  }
580
599
 
581
600
  function deleteItem(id, type) {
582
601
  pendingConfirmAction = async () => {
583
- const url = type === 'reminder' ? '/api/reminders/' + id : '/api/followups/' + id;
584
- const res = await fetch(url, { method: 'DELETE' });
585
- const data = await res.json();
586
- if (data.success) {
587
- showToast('Deleted ' + id);
588
- loadOpsData();
589
- } else {
590
- showToast(data.error || 'Failed', 'error');
602
+ try {
603
+ const url = type === 'reminder' ? '/api/reminders/' + id : '/api/followups/' + id;
604
+ const res = await fetch(url, { method: 'DELETE' });
605
+ const data = await res.json();
606
+ if (data.success) {
607
+ showToast('Deleted ' + id);
608
+ loadOpsData();
609
+ } else {
610
+ showToast(data.error || data.detail || 'Delete failed (HTTP ' + res.status + ')', 'error');
611
+ }
612
+ } catch (err) {
613
+ showToast('Delete error: ' + err.message, 'error');
591
614
  }
592
615
  };
593
616
  document.getElementById('confirm-message').textContent = 'Delete ' + id + '? This cannot be undone.';
@@ -693,10 +716,10 @@
693
716
  closeModal();
694
717
  loadOpsData();
695
718
  } else {
696
- showToast(data.error || 'Failed', 'error');
719
+ showToast(data.error || data.detail || 'Save failed (HTTP ' + res.status + ')', 'error');
697
720
  }
698
721
  } catch (err) {
699
- showToast('Error: ' + err.message, 'error');
722
+ showToast('Save error: ' + err.message, 'error');
700
723
  }
701
724
  }
702
725
 
@@ -120,6 +120,11 @@
120
120
 
121
121
  try {
122
122
  const resp = await fetch(`/api/sessions?limit=${PAGE_SIZE}&offset=${offset}`);
123
+ if (!resp.ok) {
124
+ let detail = `HTTP ${resp.status}`;
125
+ try { const b = await resp.json(); detail = b.error || b.detail || detail; } catch {}
126
+ throw new Error(detail);
127
+ }
123
128
  const data = await resp.json();
124
129
  const sessions = data.sessions || [];
125
130
 
@@ -136,13 +141,19 @@
136
141
  btn.disabled = false;
137
142
  }
138
143
  } catch (err) {
139
- btn.textContent = 'Error — retry';
144
+ btn.textContent = 'Error: ' + err.message + ' — retry';
140
145
  btn.disabled = false;
141
146
  }
142
147
  }
143
148
 
144
149
  async function init() {
145
150
  const resp = await fetch(`/api/sessions?limit=${PAGE_SIZE}&offset=0`);
151
+ if (!resp.ok) {
152
+ let detail = `HTTP ${resp.status}`;
153
+ try { const b = await resp.json(); detail = b.error || b.detail || detail; } catch {}
154
+ document.getElementById('sessions').innerHTML = `<p class="text-sm text-red-400 text-center py-8">Error loading sessions: ${detail}</p>`;
155
+ return;
156
+ }
146
157
  const data = await resp.json();
147
158
  const sessions = data.sessions || [];
148
159
  const container = document.getElementById('sessions');
package/src/db/_schema.py CHANGED
@@ -358,6 +358,14 @@ def _m17_cron_runs(conn):
358
358
  _migrate_add_index(conn, "idx_cron_runs_started", "cron_runs", "started_at")
359
359
 
360
360
 
361
+ def _m18_skills_steps(conn):
362
+ # content: the full procedure — markdown with steps, gotchas, notes.
363
+ # Can also reference a script file via file_path column.
364
+ _migrate_add_column(conn, "skills", "content", "TEXT DEFAULT ''")
365
+ _migrate_add_column(conn, "skills", "steps", "TEXT DEFAULT '[]'")
366
+ _migrate_add_column(conn, "skills", "gotchas", "TEXT DEFAULT '[]'")
367
+
368
+
361
369
  MIGRATIONS = [
362
370
  (1, "learnings_columns", _m1_learnings_columns),
363
371
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -376,6 +384,7 @@ MIGRATIONS = [
376
384
  (15, "core_rules_tables", _m15_core_rules_tables),
377
385
  (16, "skills_tables", _m16_skills_tables),
378
386
  (17, "cron_runs", _m17_cron_runs),
387
+ (18, "skills_steps_column", _m18_skills_steps),
379
388
  ]
380
389
 
381
390
 
package/src/db/_skills.py CHANGED
@@ -39,8 +39,17 @@ def create_skill(
39
39
  linked_learnings: list | str = '[]',
40
40
  file_path: str = '',
41
41
  trust_score: int = TRUST_INITIAL,
42
+ steps: list | str = '[]',
43
+ gotchas: list | str = '[]',
44
+ content: str = '',
42
45
  ) -> dict:
43
- """Create a new skill entry."""
46
+ """Create a new skill entry.
47
+
48
+ Content can be:
49
+ - Markdown with numbered steps (auto-generated from steps/gotchas if empty)
50
+ - A reference to a script file (set file_path)
51
+ - Free-form procedure description
52
+ """
44
53
  if level not in VALID_LEVELS:
45
54
  return {"error": f"level must be one of: {', '.join(sorted(VALID_LEVELS))}"}
46
55
 
@@ -48,15 +57,31 @@ def create_skill(
48
57
  trigger_json = json.dumps(trigger_patterns) if isinstance(trigger_patterns, list) else trigger_patterns
49
58
  sessions_json = json.dumps(source_sessions) if isinstance(source_sessions, list) else source_sessions
50
59
  learnings_json = json.dumps(linked_learnings) if isinstance(linked_learnings, list) else linked_learnings
60
+ steps_json = json.dumps(steps) if isinstance(steps, list) else steps
61
+ gotchas_json = json.dumps(gotchas) if isinstance(gotchas, list) else gotchas
62
+
63
+ # Auto-generate content from steps/gotchas if not provided
64
+ if not content and steps:
65
+ steps_list = steps if isinstance(steps, list) else json.loads(steps_json)
66
+ gotchas_list = gotchas if isinstance(gotchas, list) else json.loads(gotchas_json)
67
+ lines = [f"# {name}", "", description, "", "## Steps"]
68
+ for i, s in enumerate(steps_list, 1):
69
+ lines.append(f"{i}. {s}")
70
+ if gotchas_list:
71
+ lines.extend(["", "## Gotchas"])
72
+ for g in gotchas_list:
73
+ lines.append(f"- {g}")
74
+ content = "\n".join(lines)
51
75
 
52
76
  conn = get_db()
53
77
  conn.execute(
54
78
  """INSERT INTO skills
55
79
  (id, name, description, level, trust_score, file_path, tags,
56
- trigger_patterns, source_sessions, linked_learnings)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
80
+ trigger_patterns, source_sessions, linked_learnings, content, steps, gotchas)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
58
82
  (skill_id, name, description, level, trust_score, file_path,
59
- tags_json, trigger_json, sessions_json, learnings_json),
83
+ tags_json, trigger_json, sessions_json, learnings_json,
84
+ content, steps_json, gotchas_json),
60
85
  )
61
86
  conn.commit()
62
87
 
@@ -24,18 +24,32 @@ TODAY=$(date +%Y-%m-%d)
24
24
  LOG_FILE="$LOG_DIR/${TODAY}.jsonl"
25
25
 
26
26
  # Build and write record with python3 (faster than jq on macOS when cached)
27
+ # Security: redact output of credential-related tools to avoid plaintext secrets in logs
27
28
  echo "$INPUT" | python3 -c "
28
- import json, sys
29
+ import json, sys, re
29
30
  from datetime import datetime
30
31
  d = json.load(sys.stdin)
32
+ tool_name = d.get('tool_name', 'unknown')
33
+
34
+ tool_input = d.get('tool_input')
35
+ tool_response = d.get('tool_response')
36
+
37
+ # Redact tools that handle credentials/secrets
38
+ SENSITIVE_TOOLS = ('credential', 'secret', 'token', 'password', 'apikey', 'api_key')
39
+ if any(kw in tool_name.lower() for kw in SENSITIVE_TOOLS):
40
+ tool_response = '[REDACTED]'
41
+ # Also redact input values (keep keys for debuggability)
42
+ if isinstance(tool_input, dict):
43
+ tool_input = {k: '[REDACTED]' if k not in ('servicio', 'service', 'name', 'key') else v for k, v in tool_input.items()}
44
+
31
45
  record = {
32
46
  'timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
33
47
  'session_id': d.get('session_id', 'unknown'),
34
- 'tool_name': d.get('tool_name', 'unknown'),
48
+ 'tool_name': tool_name,
35
49
  'hook_event': d.get('hook_event_name', 'unknown'),
36
50
  'tool_use_id': d.get('tool_use_id'),
37
- 'tool_input': d.get('tool_input'),
38
- 'tool_response': d.get('tool_response'),
51
+ 'tool_input': tool_input,
52
+ 'tool_response': tool_response,
39
53
  'error': d.get('error')
40
54
  }
41
55
  print(json.dumps(record))
@@ -87,6 +87,10 @@ def load_plugin(mcp, filename: str, plugins_dir: str | None = None) -> int:
87
87
  if not filename.endswith(".py"):
88
88
  filename += ".py"
89
89
 
90
+ # Reject path separators and traversal sequences before joining
91
+ if "/" in filename or "\\" in filename or ".." in filename:
92
+ raise ValueError(f"Invalid plugin filename (path separators or '..' not allowed): {filename}")
93
+
90
94
  if plugins_dir is not None:
91
95
  filepath = os.path.join(plugins_dir, filename)
92
96
  if not os.path.isfile(filepath):
@@ -106,6 +110,16 @@ def load_plugin(mcp, filename: str, plugins_dir: str | None = None) -> int:
106
110
  f"Plugin not found in repo ({PLUGINS_DIR}) or personal ({PERSONAL_PLUGINS_DIR}): {filename}"
107
111
  )
108
112
 
113
+ # Security: reject path traversal — resolved path must stay inside allowed directories
114
+ real_path = os.path.realpath(filepath)
115
+ real_plugins = os.path.realpath(PLUGINS_DIR)
116
+ real_personal = os.path.realpath(PERSONAL_PLUGINS_DIR)
117
+ if not (real_path.startswith(real_plugins + os.sep) or real_path.startswith(real_personal + os.sep)):
118
+ raise ValueError(
119
+ f"Path traversal blocked: {filename!r} resolves to {real_path}, "
120
+ f"which is outside {real_plugins} and {real_personal}"
121
+ )
122
+
109
123
  module_name = f"plugins.{filename[:-3]}"
110
124
 
111
125
  # For personal plugins (outside repo), use spec_from_file_location
@@ -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)