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.
- package/README.md +12 -6
- package/bin/nexo-brain.js +115 -21
- package/bin/postinstall.js +22 -15
- package/package.json +2 -2
- package/src/auto_update.py +193 -5
- package/src/crons/sync.py +5 -0
- package/src/dashboard/app.py +9 -2
- package/src/dashboard/templates/calendar.html +7 -5
- package/src/dashboard/templates/dashboard.html +10 -3
- package/src/dashboard/templates/inbox.html +10 -0
- package/src/dashboard/templates/operations.html +64 -41
- package/src/dashboard/templates/sessions.html +12 -1
- package/src/db/_schema.py +20 -1
- package/src/db/_skills.py +29 -4
- package/src/hooks/capture-tool-logs.sh +41 -10
- package/src/hooks/session-start.sh +4 -3
- package/src/plugin_loader.py +14 -0
- package/src/plugins/update.py +376 -26
- package/src/scripts/deep-sleep/apply_findings.py +18 -3
- package/src/scripts/deep-sleep/collect.py +38 -9
- package/src/scripts/nexo-catchup.py +29 -4
- package/src/scripts/nexo-daily-self-audit.py +21 -1
- package/src/scripts/nexo-evolution-run.py +21 -1
- package/src/scripts/nexo-postmortem-consolidator.py +34 -9
- package/src/scripts/nexo-sleep.py +32 -10
- package/src/scripts/nexo-synthesis.py +29 -9
- package/src/scripts/nexo-update.sh +109 -7
- package/src/scripts/nexo-watchdog.sh +103 -47
- package/src/server.py +65 -1
|
@@ -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/
|
|
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
|
-
|
|
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'); },
|
|
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 = '✗
|
|
320
|
-
setTimeout(() => { btn.innerHTML = originalHtml; btn.disabled = false; },
|
|
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)
|
|
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 {
|
|
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 || '
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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 || '
|
|
719
|
+
showToast(data.error || data.detail || 'Save failed (HTTP ' + res.status + ')', 'error');
|
|
697
720
|
}
|
|
698
721
|
} catch (err) {
|
|
699
|
-
showToast('
|
|
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
|
-
|
|
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':
|
|
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':
|
|
38
|
-
'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
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
96
|
-
'WHERE
|
|
95
|
+
'SELECT sid, task, started_epoch FROM sessions '
|
|
96
|
+
'WHERE (strftime(\"%s\",\"now\") - last_update_epoch) < 900'
|
|
97
97
|
).fetchall()
|
|
98
|
-
|
|
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
|
|
package/src/plugin_loader.py
CHANGED
|
@@ -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
|