nexo-brain 2.3.2 → 2.5.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.
Files changed (83) hide show
  1. package/README.md +77 -8
  2. package/bin/nexo-brain.js +230 -22
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +5 -2
  6. package/src/auto_update.py +158 -8
  7. package/src/cli.py +605 -0
  8. package/src/cognitive/_ingest.py +1 -1
  9. package/src/cognitive/_memory.py +4 -4
  10. package/src/crons/manifest.json +8 -0
  11. package/src/dashboard/app.py +709 -37
  12. package/src/dashboard/templates/adaptive.html +112 -218
  13. package/src/dashboard/templates/artifacts.html +133 -0
  14. package/src/dashboard/templates/backups.html +136 -0
  15. package/src/dashboard/templates/base.html +413 -0
  16. package/src/dashboard/templates/calendar.html +523 -652
  17. package/src/dashboard/templates/chat.html +356 -0
  18. package/src/dashboard/templates/claims.html +259 -0
  19. package/src/dashboard/templates/cortex.html +262 -0
  20. package/src/dashboard/templates/credentials.html +128 -0
  21. package/src/dashboard/templates/crons.html +370 -0
  22. package/src/dashboard/templates/dashboard.html +384 -572
  23. package/src/dashboard/templates/dreams.html +252 -0
  24. package/src/dashboard/templates/email.html +160 -0
  25. package/src/dashboard/templates/evolution.html +189 -0
  26. package/src/dashboard/templates/feed.html +249 -0
  27. package/src/dashboard/templates/followup_health.html +170 -0
  28. package/src/dashboard/templates/graph.html +191 -269
  29. package/src/dashboard/templates/guard.html +259 -0
  30. package/src/dashboard/templates/inbox.html +220 -336
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +498 -652
  33. package/src/dashboard/templates/plugins.html +185 -0
  34. package/src/dashboard/templates/rules.html +246 -0
  35. package/src/dashboard/templates/sentiment.html +247 -0
  36. package/src/dashboard/templates/sessions.html +215 -171
  37. package/src/dashboard/templates/skills.html +329 -0
  38. package/src/dashboard/templates/somatic.html +68 -172
  39. package/src/dashboard/templates/triggers.html +133 -0
  40. package/src/dashboard/templates/trust.html +360 -0
  41. package/src/db/__init__.py +5 -0
  42. package/src/db/_schema.py +25 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +983 -252
  45. package/src/doctor/__init__.py +1 -0
  46. package/src/doctor/formatters.py +52 -0
  47. package/src/doctor/models.py +44 -0
  48. package/src/doctor/orchestrator.py +42 -0
  49. package/src/doctor/providers/__init__.py +1 -0
  50. package/src/doctor/providers/boot.py +206 -0
  51. package/src/doctor/providers/deep.py +292 -0
  52. package/src/doctor/providers/runtime.py +686 -0
  53. package/src/hooks/capture-tool-logs.sh +18 -4
  54. package/src/hooks/post-compact.sh +5 -1
  55. package/src/hooks/pre-compact.sh +1 -1
  56. package/src/plugin_loader.py +14 -0
  57. package/src/plugins/doctor.py +36 -0
  58. package/src/plugins/evolution.py +2 -1
  59. package/src/plugins/skills.py +135 -175
  60. package/src/requirements.txt +1 -0
  61. package/src/script_registry.py +322 -0
  62. package/src/scripts/deep-sleep/apply_findings.py +63 -33
  63. package/src/scripts/deep-sleep/collect.py +38 -9
  64. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  65. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  66. package/src/scripts/deep-sleep/synthesize.py +37 -1
  67. package/src/scripts/nexo-dashboard.sh +29 -0
  68. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  69. package/src/scripts/nexo-evolution-run.py +2 -1
  70. package/src/scripts/nexo-learning-housekeep.py +1 -1
  71. package/src/scripts/nexo-watchdog.sh +1 -1
  72. package/src/server.py +9 -5
  73. package/src/skills/run-runtime-doctor/guide.md +12 -0
  74. package/src/skills/run-runtime-doctor/script.py +21 -0
  75. package/src/skills/run-runtime-doctor/skill.json +25 -0
  76. package/src/skills_runtime.py +347 -0
  77. package/src/tools_menu.py +3 -2
  78. package/src/tools_sessions.py +126 -0
  79. package/src/user_context.py +46 -0
  80. package/templates/nexo_helper.py +45 -0
  81. package/templates/script-template.py +44 -0
  82. package/templates/skill-script-template.py +39 -0
  83. package/templates/skill-template.md +33 -0
@@ -0,0 +1,133 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Prospective Triggers{% endblock %}
4
+ {% block page_title %}Prospective Triggers{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="space-y-5">
8
+ <!-- Stats -->
9
+ <div class="grid grid-cols-3 gap-4">
10
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-4 card">
11
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-2">Total Triggers</div>
12
+ <div class="text-xl font-mono font-semibold text-slate-200" id="total-triggers">--</div>
13
+ </div>
14
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-4 card">
15
+ <div class="flex items-center gap-2 mb-2">
16
+ <span class="w-2 h-2 rounded-full bg-emerald-500 glow-dot" style="color: #10B981;"></span>
17
+ <div class="text-xs uppercase tracking-wider text-emerald-400/70 font-medium">Armed</div>
18
+ </div>
19
+ <div class="text-xl font-mono font-semibold text-emerald-400" id="armed-count">--</div>
20
+ </div>
21
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-4 card">
22
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-2">Fired</div>
23
+ <div class="text-xl font-mono font-semibold text-slate-400" id="fired-count">--</div>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Triggers grid -->
28
+ <div>
29
+ <div class="flex items-center gap-3 mb-3">
30
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium">Armed Triggers</div>
31
+ <span class="w-2 h-2 rounded-full bg-emerald-500 glow-dot" style="color: #10B981;"></span>
32
+ </div>
33
+ <div class="grid grid-cols-2 gap-4" id="armed-triggers">
34
+ <div class="text-xs text-slate-600 py-4 text-center col-span-2">loading...</div>
35
+ </div>
36
+ </div>
37
+
38
+ <div>
39
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">Fired / Inactive</div>
40
+ <div class="grid grid-cols-2 gap-4" id="fired-triggers">
41
+ <div class="text-xs text-slate-600 py-4 text-center col-span-2">loading...</div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <style>
47
+ @keyframes triggerPulse {
48
+ 0%, 100% { border-color: rgba(16,185,129,0.2); }
49
+ 50% { border-color: rgba(16,185,129,0.5); }
50
+ }
51
+ .trigger-armed { animation: triggerPulse 3s ease-in-out infinite; }
52
+ </style>
53
+ {% endblock %}
54
+
55
+ {% block scripts %}
56
+ <script>
57
+ function renderTrigger(t, isArmed) {
58
+ const pattern = t.trigger_pattern || t.pattern || '--';
59
+ const action = t.action || '--';
60
+ const context = t.context || '';
61
+ const status = t.status || 'armed';
62
+ const firedAt = t.fired_at;
63
+
64
+ if (isArmed) {
65
+ return `<div class="bg-slate-900/50 border border-emerald-500/20 rounded-xl p-5 card trigger-armed">
66
+ <div class="flex items-center gap-2 mb-3">
67
+ <span class="relative flex h-2 w-2">
68
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
69
+ <span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
70
+ </span>
71
+ <span class="text-xs font-medium text-emerald-400 uppercase tracking-wider">Armed</span>
72
+ </div>
73
+ <div class="mb-3">
74
+ <div class="text-[10px] text-slate-500 uppercase mb-1">Pattern</div>
75
+ <div class="text-sm text-slate-200 font-mono bg-slate-800/50 rounded-lg px-3 py-2">${escapeHtml(pattern)}</div>
76
+ </div>
77
+ <div class="mb-3">
78
+ <div class="text-[10px] text-slate-500 uppercase mb-1">Action</div>
79
+ <div class="text-xs text-slate-300">${escapeHtml(action)}</div>
80
+ </div>
81
+ ${context ? `<div class="text-[10px] text-slate-500 mt-2">${escapeHtml(context)}</div>` : ''}
82
+ </div>`;
83
+ } else {
84
+ return `<div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card opacity-60 hover:opacity-80 transition-opacity">
85
+ <div class="flex items-center gap-2 mb-3">
86
+ <span class="w-2 h-2 rounded-full bg-slate-600"></span>
87
+ <span class="text-xs font-medium text-slate-500 uppercase tracking-wider">${escapeHtml(status)}</span>
88
+ ${firedAt ? `<span class="ml-auto text-[10px] text-slate-600">${relativeTime(firedAt)}</span>` : ''}
89
+ </div>
90
+ <div class="mb-3">
91
+ <div class="text-[10px] text-slate-600 uppercase mb-1">Pattern</div>
92
+ <div class="text-xs text-slate-400 font-mono bg-slate-800/30 rounded-lg px-3 py-2">${escapeHtml(pattern)}</div>
93
+ </div>
94
+ <div>
95
+ <div class="text-[10px] text-slate-600 uppercase mb-1">Action</div>
96
+ <div class="text-xs text-slate-500">${escapeHtml(action)}</div>
97
+ </div>
98
+ ${context ? `<div class="text-[10px] text-slate-600 mt-2">${escapeHtml(context)}</div>` : ''}
99
+ </div>`;
100
+ }
101
+ }
102
+
103
+ async function loadTriggers() {
104
+ const data = await fetchJSON('/api/triggers');
105
+ if (!data) return;
106
+
107
+ const triggers = data.triggers || [];
108
+ const armed = triggers.filter(t => t.status === 'armed' || t.status === 'active');
109
+ const fired = triggers.filter(t => t.status !== 'armed' && t.status !== 'active');
110
+
111
+ document.getElementById('total-triggers').textContent = triggers.length;
112
+ document.getElementById('armed-count').textContent = data.armed ?? armed.length;
113
+ document.getElementById('fired-count').textContent = data.fired ?? fired.length;
114
+
115
+ const armedContainer = document.getElementById('armed-triggers');
116
+ if (armed.length) {
117
+ armedContainer.innerHTML = armed.map(t => renderTrigger(t, true)).join('');
118
+ } else {
119
+ armedContainer.innerHTML = '<div class="col-span-2 bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 text-xs text-slate-500 text-center">No armed triggers</div>';
120
+ }
121
+
122
+ const firedContainer = document.getElementById('fired-triggers');
123
+ if (fired.length) {
124
+ firedContainer.innerHTML = fired.map(t => renderTrigger(t, false)).join('');
125
+ } else {
126
+ firedContainer.innerHTML = '<div class="col-span-2 bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 text-xs text-slate-500 text-center">No fired triggers</div>';
127
+ }
128
+ }
129
+
130
+ loadTriggers();
131
+ setInterval(loadTriggers, 60000);
132
+ </script>
133
+ {% endblock %}
@@ -0,0 +1,360 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Trust Deep-Dive{% endblock %}
4
+ {% block page_title %}Trust Deep-Dive{% endblock %}
5
+
6
+ {% block head %}
7
+ <style>
8
+ .gauge-ring {
9
+ transition: stroke-dashoffset 1.2s cubic-bezier(0.22, 1, 0.36, 1);
10
+ }
11
+ .score-value {
12
+ transition: all 0.8s ease;
13
+ }
14
+ @keyframes pulseGlow {
15
+ 0%, 100% { filter: drop-shadow(0 0 8px rgba(124, 58, 237, 0.3)); }
16
+ 50% { filter: drop-shadow(0 0 20px rgba(124, 58, 237, 0.6)); }
17
+ }
18
+ .gauge-container {
19
+ animation: pulseGlow 3s ease-in-out infinite;
20
+ }
21
+ .event-card {
22
+ transition: all 0.2s ease;
23
+ animation: slideIn 0.3s ease-out forwards;
24
+ opacity: 0;
25
+ }
26
+ .event-card:hover {
27
+ border-color: rgba(124, 58, 237, 0.3);
28
+ transform: translateX(2px);
29
+ }
30
+ .chart-container {
31
+ position: relative;
32
+ }
33
+ .delta-positive { color: #34d399; }
34
+ .delta-negative { color: #f87171; }
35
+ .delta-badge-positive { background: rgba(52, 211, 153, 0.1); border-color: rgba(52, 211, 153, 0.2); color: #34d399; }
36
+ .delta-badge-negative { background: rgba(248, 113, 113, 0.1); border-color: rgba(248, 113, 113, 0.2); color: #f87171; }
37
+ @keyframes slideIn {
38
+ from { opacity: 0; transform: translateX(-8px); }
39
+ to { opacity: 1; transform: translateX(0); }
40
+ }
41
+ .filter-btn.active {
42
+ background: rgba(124, 58, 237, 0.2);
43
+ color: #a78bfa;
44
+ border-color: rgba(124, 58, 237, 0.3);
45
+ }
46
+ </style>
47
+ {% endblock %}
48
+
49
+ {% block header_actions %}
50
+ <div class="flex items-center gap-1">
51
+ <button class="filter-btn active text-[10px] px-2 py-1 rounded border border-slate-700 text-slate-400 hover:text-slate-200 transition-colors" data-filter="all" onclick="setFilter('all')">All</button>
52
+ <button class="filter-btn text-[10px] px-2 py-1 rounded border border-slate-700 text-slate-400 hover:text-slate-200 transition-colors" data-filter="positive" onclick="setFilter('positive')">Positive</button>
53
+ <button class="filter-btn text-[10px] px-2 py-1 rounded border border-slate-700 text-slate-400 hover:text-slate-200 transition-colors" data-filter="negative" onclick="setFilter('negative')">Negative</button>
54
+ </div>
55
+ {% endblock %}
56
+
57
+ {% block content %}
58
+ <!-- Gauge + Stats Row -->
59
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-6">
60
+ <!-- Trust Gauge -->
61
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-6 flex flex-col items-center justify-center">
62
+ <div class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">Current Trust Score</div>
63
+ <div class="gauge-container relative" style="width: 200px; height: 200px;">
64
+ <svg viewBox="0 0 200 200" class="w-full h-full">
65
+ <!-- Background ring -->
66
+ <circle cx="100" cy="100" r="85" fill="none" stroke="rgba(51,65,85,0.3)" stroke-width="10"
67
+ stroke-dasharray="534" stroke-dashoffset="0" stroke-linecap="round"
68
+ transform="rotate(-90 100 100)"/>
69
+ <!-- Score ring -->
70
+ <circle id="gauge-ring" cx="100" cy="100" r="85" fill="none" stroke="url(#gaugeGradient)" stroke-width="10"
71
+ stroke-dasharray="534" stroke-dashoffset="534" stroke-linecap="round"
72
+ transform="rotate(-90 100 100)" class="gauge-ring"/>
73
+ <!-- Gradient -->
74
+ <defs>
75
+ <linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="100%">
76
+ <stop offset="0%" stop-color="#7C3AED"/>
77
+ <stop offset="50%" stop-color="#a78bfa"/>
78
+ <stop offset="100%" stop-color="#34d399"/>
79
+ </linearGradient>
80
+ </defs>
81
+ </svg>
82
+ <div class="absolute inset-0 flex flex-col items-center justify-center">
83
+ <span id="gauge-score" class="text-5xl font-display font-bold text-white score-value">--</span>
84
+ <span class="text-xs text-slate-500 mt-1">/ 100</span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Weekly Stats -->
90
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 flex flex-col justify-center">
91
+ <div class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-4">This Week</div>
92
+ <div class="space-y-4">
93
+ <div>
94
+ <div class="flex items-center justify-between mb-1">
95
+ <span class="text-xs text-slate-400">Average Score</span>
96
+ <span class="text-sm font-mono font-semibold text-slate-200" id="stat-avg">--</span>
97
+ </div>
98
+ <div class="h-1 bg-slate-800 rounded-full overflow-hidden">
99
+ <div class="h-full bg-violet-500 rounded-full transition-all duration-700" id="stat-avg-bar" style="width:0%"></div>
100
+ </div>
101
+ </div>
102
+ <div class="flex items-center justify-between">
103
+ <span class="text-xs text-slate-400">Biggest Gain</span>
104
+ <span class="text-sm font-mono font-semibold delta-positive" id="stat-gain">--</span>
105
+ </div>
106
+ <div class="flex items-center justify-between">
107
+ <span class="text-xs text-slate-400">Biggest Drop</span>
108
+ <span class="text-sm font-mono font-semibold delta-negative" id="stat-drop">--</span>
109
+ </div>
110
+ <div class="flex items-center justify-between">
111
+ <span class="text-xs text-slate-400">Total Events</span>
112
+ <span class="text-sm font-mono font-semibold text-slate-200" id="stat-events">--</span>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <!-- Score History Chart -->
118
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5">
119
+ <div class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-3">Score History</div>
120
+ <div class="chart-container" style="height: 180px;">
121
+ <canvas id="history-chart" width="400" height="180"></canvas>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Event Timeline -->
127
+ <div>
128
+ <h2 class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-3 flex items-center gap-2">
129
+ <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
130
+ Trust Events
131
+ <span class="text-slate-600 font-mono" id="event-count"></span>
132
+ </h2>
133
+ <div class="space-y-2" id="event-timeline">
134
+ <div class="text-xs text-slate-600 text-center py-8">Loading trust events...</div>
135
+ </div>
136
+ </div>
137
+ {% endblock %}
138
+
139
+ {% block scripts %}
140
+ <script>
141
+ const REFRESH_MS = 60000;
142
+ let allEvents = [];
143
+ let currentFilter = 'all';
144
+
145
+ function setFilter(filter) {
146
+ currentFilter = filter;
147
+ document.querySelectorAll('.filter-btn').forEach(b => {
148
+ b.classList.toggle('active', b.dataset.filter === filter);
149
+ });
150
+ renderEvents();
151
+ }
152
+
153
+ function updateGauge(score) {
154
+ const ring = document.getElementById('gauge-ring');
155
+ const scoreEl = document.getElementById('gauge-score');
156
+ const circumference = 534; // 2 * PI * 85
157
+ const offset = circumference - (score / 100) * circumference;
158
+ ring.style.strokeDashoffset = offset;
159
+ scoreEl.textContent = Math.round(score);
160
+ }
161
+
162
+ function drawChart(history) {
163
+ const canvas = document.getElementById('history-chart');
164
+ const ctx = canvas.getContext('2d');
165
+ const dpr = window.devicePixelRatio || 1;
166
+ const rect = canvas.getBoundingClientRect();
167
+ canvas.width = rect.width * dpr;
168
+ canvas.height = rect.height * dpr;
169
+ ctx.scale(dpr, dpr);
170
+ const w = rect.width;
171
+ const h = rect.height;
172
+
173
+ ctx.clearRect(0, 0, w, h);
174
+
175
+ if (!history || history.length < 2) {
176
+ ctx.fillStyle = '#475569';
177
+ ctx.font = '12px "Space Grotesk"';
178
+ ctx.textAlign = 'center';
179
+ ctx.fillText('Not enough data for chart', w / 2, h / 2);
180
+ return;
181
+ }
182
+
183
+ const scores = history.map(h => h.score);
184
+ const minScore = Math.max(0, Math.min(...scores) - 5);
185
+ const maxScore = Math.min(100, Math.max(...scores) + 5);
186
+ const range = maxScore - minScore || 1;
187
+
188
+ const padX = 10, padY = 15;
189
+ const chartW = w - padX * 2;
190
+ const chartH = h - padY * 2;
191
+
192
+ // Grid lines
193
+ ctx.strokeStyle = 'rgba(51, 65, 85, 0.3)';
194
+ ctx.lineWidth = 1;
195
+ for (let i = 0; i <= 4; i++) {
196
+ const y = padY + (chartH / 4) * i;
197
+ ctx.beginPath();
198
+ ctx.moveTo(padX, y);
199
+ ctx.lineTo(w - padX, y);
200
+ ctx.stroke();
201
+ }
202
+
203
+ // Gradient fill
204
+ const gradient = ctx.createLinearGradient(0, padY, 0, h - padY);
205
+ gradient.addColorStop(0, 'rgba(124, 58, 237, 0.15)');
206
+ gradient.addColorStop(1, 'rgba(124, 58, 237, 0)');
207
+
208
+ // Line path
209
+ const points = history.map((item, i) => ({
210
+ x: padX + (i / (history.length - 1)) * chartW,
211
+ y: padY + chartH - ((item.score - minScore) / range) * chartH
212
+ }));
213
+
214
+ // Fill area
215
+ ctx.beginPath();
216
+ ctx.moveTo(points[0].x, h - padY);
217
+ points.forEach(p => ctx.lineTo(p.x, p.y));
218
+ ctx.lineTo(points[points.length - 1].x, h - padY);
219
+ ctx.closePath();
220
+ ctx.fillStyle = gradient;
221
+ ctx.fill();
222
+
223
+ // Line
224
+ ctx.beginPath();
225
+ ctx.moveTo(points[0].x, points[0].y);
226
+ for (let i = 1; i < points.length; i++) {
227
+ const prev = points[i - 1];
228
+ const curr = points[i];
229
+ const cpx = (prev.x + curr.x) / 2;
230
+ ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
231
+ }
232
+ ctx.strokeStyle = '#7C3AED';
233
+ ctx.lineWidth = 2;
234
+ ctx.stroke();
235
+
236
+ // Dots
237
+ points.forEach((p, i) => {
238
+ if (i === points.length - 1 || history.length <= 20 || i % Math.ceil(history.length / 15) === 0) {
239
+ ctx.beginPath();
240
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
241
+ ctx.fillStyle = '#7C3AED';
242
+ ctx.fill();
243
+ ctx.strokeStyle = '#030712';
244
+ ctx.lineWidth = 1.5;
245
+ ctx.stroke();
246
+ }
247
+ });
248
+
249
+ // Last point highlight
250
+ const last = points[points.length - 1];
251
+ ctx.beginPath();
252
+ ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
253
+ ctx.fillStyle = '#a78bfa';
254
+ ctx.fill();
255
+ ctx.strokeStyle = '#030712';
256
+ ctx.lineWidth = 2;
257
+ ctx.stroke();
258
+ }
259
+
260
+ function renderEvents() {
261
+ const container = document.getElementById('event-timeline');
262
+ const countEl = document.getElementById('event-count');
263
+
264
+ let filtered = allEvents;
265
+ if (currentFilter === 'positive') filtered = allEvents.filter(e => (e.delta || 0) > 0);
266
+ else if (currentFilter === 'negative') filtered = allEvents.filter(e => (e.delta || 0) < 0);
267
+
268
+ countEl.textContent = `(${filtered.length})`;
269
+
270
+ if (filtered.length === 0) {
271
+ container.innerHTML = '<div class="text-xs text-slate-600 text-center py-8">No events match filter</div>';
272
+ return;
273
+ }
274
+
275
+ container.innerHTML = filtered.slice(0, 50).map((e, idx) => {
276
+ const delta = e.delta || 0;
277
+ const isPositive = delta >= 0;
278
+ const deltaStr = (isPositive ? '+' : '') + delta.toFixed(2);
279
+ const badgeClass = isPositive ? 'delta-badge-positive' : 'delta-badge-negative';
280
+ const event = e.event || e.description || 'Trust event';
281
+ const context = e.context || '';
282
+ const when = e.created_at ? relativeTime(e.created_at) : '';
283
+ const score = e.score != null ? e.score.toFixed(1) : '';
284
+ const delay = Math.min(idx * 0.05, 1);
285
+
286
+ return `<div class="event-card flex items-start gap-3 bg-slate-900/50 border border-slate-800/50 rounded-xl p-4" style="animation-delay:${delay}s">
287
+ <div class="flex-shrink-0 mt-0.5">
288
+ <span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border text-xs font-mono font-bold ${badgeClass}">
289
+ ${deltaStr}
290
+ </span>
291
+ </div>
292
+ <div class="flex-1 min-w-0">
293
+ <div class="flex items-center gap-2 mb-1">
294
+ <span class="text-sm text-slate-200 font-medium">${escapeHtml(event)}</span>
295
+ ${score ? `<span class="text-[10px] font-mono text-slate-500 ml-auto flex-shrink-0">${score}</span>` : ''}
296
+ </div>
297
+ ${context ? `<p class="text-xs text-slate-500 leading-relaxed">${escapeHtml(context)}</p>` : ''}
298
+ <span class="text-[10px] text-slate-600 font-mono mt-1 inline-block">${escapeHtml(when)}</span>
299
+ </div>
300
+ </div>`;
301
+ }).join('');
302
+ }
303
+
304
+ async function loadTrust() {
305
+ const [trustData, eventsData] = await Promise.all([
306
+ fetchJSON('/api/trust'),
307
+ fetchJSON('/api/trust/events?limit=50')
308
+ ]);
309
+
310
+ if (trustData) {
311
+ const score = trustData.current_score != null ? trustData.current_score : 0;
312
+ updateGauge(score);
313
+
314
+ const history = trustData.history || [];
315
+ drawChart(history);
316
+
317
+ // Weekly stats
318
+ const now = new Date();
319
+ const weekAgo = new Date(now - 7 * 86400000);
320
+ const weekEvents = history.filter(h => new Date(h.created_at) >= weekAgo);
321
+ const weekScores = weekEvents.map(h => h.score).filter(s => s != null);
322
+ const weekDeltas = weekEvents.map(h => h.delta).filter(d => d != null);
323
+
324
+ if (weekScores.length > 0) {
325
+ const avg = weekScores.reduce((a, b) => a + b, 0) / weekScores.length;
326
+ document.getElementById('stat-avg').textContent = avg.toFixed(1);
327
+ document.getElementById('stat-avg-bar').style.width = avg + '%';
328
+ }
329
+
330
+ if (weekDeltas.length > 0) {
331
+ const maxGain = Math.max(...weekDeltas);
332
+ const maxDrop = Math.min(...weekDeltas);
333
+ document.getElementById('stat-gain').textContent = maxGain > 0 ? '+' + maxGain.toFixed(2) : '0';
334
+ document.getElementById('stat-drop').textContent = maxDrop < 0 ? maxDrop.toFixed(2) : '0';
335
+ }
336
+
337
+ document.getElementById('stat-events').textContent = formatNumber(history.length);
338
+ }
339
+
340
+ if (eventsData) {
341
+ allEvents = eventsData.events || eventsData || [];
342
+ renderEvents();
343
+ }
344
+ }
345
+
346
+ // Handle resize for chart
347
+ let resizeTimer;
348
+ window.addEventListener('resize', () => {
349
+ clearTimeout(resizeTimer);
350
+ resizeTimer = setTimeout(() => {
351
+ // Re-fetch to redraw chart
352
+ loadTrust();
353
+ }, 250);
354
+ });
355
+
356
+ // Init
357
+ loadTrust();
358
+ setInterval(loadTrust, REFRESH_MS);
359
+ </script>
360
+ {% endblock %}
@@ -99,4 +99,9 @@ from db._skills import (
99
99
  update_skill, delete_skill,
100
100
  record_usage as record_skill_usage,
101
101
  match_skills, merge_skills, get_skill_stats, decay_unused_skills,
102
+ get_featured_skills, get_skill_execution_spec, resolve_skill_paths,
103
+ validate_skill_params, render_command_template, sync_skill_directories,
104
+ import_skill_from_directory, approve_skill, collect_scriptable_skill_candidates,
105
+ collect_skill_improvement_candidates, materialize_personal_skill_definition,
106
+ get_skill_health_report,
102
107
  )
package/src/db/_schema.py CHANGED
@@ -358,6 +358,29 @@ 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
+
369
+ def _m19_skills_v2(conn):
370
+ _migrate_add_column(conn, "skills", "mode", "TEXT DEFAULT 'guide'")
371
+ _migrate_add_column(conn, "skills", "source_kind", "TEXT DEFAULT 'personal'")
372
+ _migrate_add_column(conn, "skills", "execution_level", "TEXT DEFAULT 'none'")
373
+ _migrate_add_column(conn, "skills", "approval_required", "INTEGER DEFAULT 0")
374
+ _migrate_add_column(conn, "skills", "approved_at", "TEXT DEFAULT ''")
375
+ _migrate_add_column(conn, "skills", "approved_by", "TEXT DEFAULT ''")
376
+ _migrate_add_column(conn, "skills", "params_schema", "TEXT DEFAULT '{}'")
377
+ _migrate_add_column(conn, "skills", "command_template", "TEXT DEFAULT '{}'")
378
+ _migrate_add_column(conn, "skills", "executable_entry", "TEXT DEFAULT ''")
379
+ _migrate_add_column(conn, "skills", "stable_after_uses", "INTEGER DEFAULT 10")
380
+ _migrate_add_column(conn, "skills", "definition_path", "TEXT DEFAULT ''")
381
+ _migrate_add_column(conn, "skills", "last_reviewed_at", "TEXT DEFAULT NULL")
382
+
383
+
361
384
  MIGRATIONS = [
362
385
  (1, "learnings_columns", _m1_learnings_columns),
363
386
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -376,6 +399,8 @@ MIGRATIONS = [
376
399
  (15, "core_rules_tables", _m15_core_rules_tables),
377
400
  (16, "skills_tables", _m16_skills_tables),
378
401
  (17, "cron_runs", _m17_cron_runs),
402
+ (18, "skills_steps_column", _m18_skills_steps),
403
+ (19, "skills_v2", _m19_skills_v2),
379
404
  ]
380
405
 
381
406
 
@@ -435,4 +460,3 @@ def get_schema_version() -> int:
435
460
  except Exception:
436
461
  return 0
437
462
 
438
-
@@ -15,8 +15,28 @@ def local_time_str() -> str:
15
15
  return datetime.now().strftime("%H:%M")
16
16
 
17
17
 
18
+ import re
19
+ _SID_EXACT = re.compile(r'^nexo-\d+-\d+$')
20
+ _SID_SEARCH = re.compile(r'nexo-\d+-\d+')
21
+
22
+ def _validate_sid(sid: str) -> str:
23
+ """Validate and sanitize SID. Extracts clean SID if embedded in text."""
24
+ if not sid:
25
+ raise ValueError("SID cannot be empty")
26
+ sid = sid.strip()
27
+ # Clean SID — most common case
28
+ if _SID_EXACT.match(sid):
29
+ return sid
30
+ # Extract SID from text like "SID: nexo-1234-5678\nOther stuff..."
31
+ match = _SID_SEARCH.search(sid)
32
+ if match:
33
+ return match.group(0)
34
+ raise ValueError(f"Invalid SID format: {sid[:80]}")
35
+
36
+
18
37
  def register_session(sid: str, task: str, claude_session_id: str = "") -> dict:
19
38
  """Register or re-register a session."""
39
+ sid = _validate_sid(sid)
20
40
  conn = get_db()
21
41
  now = now_epoch()
22
42
  conn.execute(
@@ -35,6 +55,7 @@ def update_session(sid: str, task: str | None) -> dict:
35
55
  sid: Session ID.
36
56
  task: New task description, or None to keep current task (keepalive touch).
37
57
  """
58
+ sid = _validate_sid(sid)
38
59
  conn = get_db()
39
60
  now = now_epoch()
40
61
  row = conn.execute("SELECT started_epoch, task FROM sessions WHERE sid = ?", (sid,)).fetchone()
@@ -57,6 +78,7 @@ def update_session(sid: str, task: str | None) -> dict:
57
78
 
58
79
  def complete_session(sid: str):
59
80
  """Remove session and its tracked files."""
81
+ sid = _validate_sid(sid)
60
82
  conn = get_db()
61
83
  conn.execute("PRAGMA foreign_keys=ON")
62
84
  conn.execute("DELETE FROM tracked_files WHERE sid = ?", (sid,))