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.
@@ -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
 
@@ -399,6 +408,7 @@ def run_migrations(conn=None):
399
408
 
400
409
  applied = {r[0] for r in conn.execute("SELECT version FROM schema_migrations").fetchall()}
401
410
 
411
+ failed = []
402
412
  for version, name, fn in MIGRATIONS:
403
413
  if version not in applied:
404
414
  try:
@@ -409,9 +419,18 @@ def run_migrations(conn=None):
409
419
  )
410
420
  conn.commit()
411
421
  except Exception as e:
412
- # Log but don't crash — partial migration is better than no server
422
+ conn.rollback()
413
423
  import sys
414
424
  print(f"[MIGRATION] v{version} ({name}) failed: {e}", file=sys.stderr)
425
+ failed.append((version, name, str(e)))
426
+ # Stop on first failure — don't run subsequent migrations
427
+ # against a potentially inconsistent schema
428
+ break
429
+
430
+ if failed:
431
+ raise RuntimeError(
432
+ f"Migration failed: v{failed[0][0]} ({failed[0][1]}): {failed[0][2]}"
433
+ )
415
434
 
416
435
  return len(MIGRATIONS) - len(applied)
417
436
 
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,25 +24,43 @@ 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))
42
56
  " >> "$LOG_FILE" 2>/dev/null
43
57
 
44
- # ── Layer 1: Auto-diary every 10 tool calls ─────────────────────────
45
- COUNTER_FILE="$NEXO_HOME/operations/.tool-call-count"
58
+ # ── Layer 1: Auto-diary every 10 tool calls (session-scoped) ─────────
59
+ # Extract session_id for per-session counters (prevents cross-terminal contamination)
60
+ SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','global'))" 2>/dev/null || echo "global")
61
+ COUNTER_DIR="$NEXO_HOME/operations/counters"
62
+ mkdir -p "$COUNTER_DIR"
63
+ COUNTER_FILE="$COUNTER_DIR/.tool-call-count-${SESSION_ID}"
46
64
  NEXO_DB="$NEXO_HOME/data/nexo.db"
47
65
 
48
66
  # Increment counter (atomic: read+write in one step)
@@ -86,12 +104,25 @@ if not entries:
86
104
 
87
105
  tools_summary = ', '.join(entries[-10:])
88
106
 
89
- # Get current session and task from sessions table
107
+ # Get session by claude session_id (scoped), fallback to most recent
108
+ session_id = '$SESSION_ID'
90
109
  conn = sqlite3.connect(db_path, timeout=2)
91
110
  conn.row_factory = sqlite3.Row
92
- row = conn.execute(
93
- 'SELECT sid, task FROM sessions ORDER BY last_update_epoch DESC LIMIT 1'
94
- ).fetchone()
111
+
112
+ # Try to find NEXO SID mapped to this claude session_id
113
+ row = None
114
+ if session_id and session_id != 'global':
115
+ row = conn.execute(
116
+ 'SELECT sid, task FROM sessions WHERE claude_session_id = ? LIMIT 1',
117
+ (session_id,)
118
+ ).fetchone()
119
+
120
+ # Fallback: most recent active session
121
+ if not row:
122
+ row = conn.execute(
123
+ 'SELECT sid, task FROM sessions ORDER BY last_update_epoch DESC LIMIT 1'
124
+ ).fetchone()
125
+
95
126
  if not row:
96
127
  conn.close()
97
128
  sys.exit(0)
@@ -92,10 +92,11 @@ try:
92
92
 
93
93
  try:
94
94
  rows = db.execute(
95
- 'SELECT sid, task, started FROM sessions '
96
- 'WHERE completed=0 AND (strftime(\"%s\",\"now\") - last_update) < 900'
95
+ 'SELECT sid, task, started_epoch FROM sessions '
96
+ 'WHERE (strftime(\"%s\",\"now\") - last_update_epoch) < 900'
97
97
  ).fetchall()
98
- sessions = [{'sid': r['sid'], 'task': r['task'], 'started': r['started'][:16]} for r in rows]
98
+ from datetime import datetime as _dt
99
+ sessions = [{'sid': r['sid'], 'task': r['task'], 'started': _dt.fromtimestamp(r['started_epoch']).strftime('%Y-%m-%d %H:%M') if r['started_epoch'] else '?'} for r in rows]
99
100
  except Exception:
100
101
  pass
101
102
 
@@ -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