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 +12 -6
- package/bin/nexo-brain.js +23 -12
- package/package.json +1 -1
- 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 +9 -0
- package/src/db/_skills.py +29 -4
- package/src/hooks/capture-tool-logs.sh +18 -4
- package/src/plugin_loader.py +14 -0
- package/src/scripts/deep-sleep/apply_findings.py +18 -3
- package/src/scripts/deep-sleep/collect.py +38 -9
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
|
|
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
|
|
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.
|
|
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 --
|
|
508
|
+
claude --dangerously-skip-permissions "."
|
|
503
509
|
```
|
|
504
|
-
`--
|
|
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`
|
|
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
|
|
1884
|
-
|
|
1885
|
-
: path.join(require("os").homedir(), ".bash_profile");
|
|
1883
|
+
const homeDir = require("os").homedir();
|
|
1884
|
+
const rcFiles = [];
|
|
1886
1885
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
+
"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": {
|
package/src/dashboard/app.py
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
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
|
|
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':
|
|
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))
|
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
|
|
@@ -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,
|
|
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)
|