nexo-brain 2.4.0 → 2.5.1

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 (81) hide show
  1. package/README.md +80 -4
  2. package/bin/nexo-brain.js +238 -12
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +11 -3
  6. package/src/auto_update.py +193 -9
  7. package/src/cli.py +719 -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 +700 -35
  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 -654
  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 +383 -578
  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 -346
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +521 -698
  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 -182
  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 +16 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +980 -274
  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/evolution_cycle.py +86 -6
  54. package/src/hooks/post-compact.sh +5 -1
  55. package/src/hooks/pre-compact.sh +1 -1
  56. package/src/plugins/doctor.py +36 -0
  57. package/src/plugins/evolution.py +11 -3
  58. package/src/plugins/skills.py +135 -175
  59. package/src/requirements.txt +1 -0
  60. package/src/script_registry.py +322 -0
  61. package/src/scripts/deep-sleep/apply_findings.py +63 -48
  62. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  63. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  64. package/src/scripts/deep-sleep/synthesize.py +37 -1
  65. package/src/scripts/nexo-dashboard.sh +29 -0
  66. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  67. package/src/scripts/nexo-evolution-run.py +141 -54
  68. package/src/scripts/nexo-learning-housekeep.py +1 -1
  69. package/src/scripts/nexo-watchdog.sh +1 -1
  70. package/src/server.py +9 -5
  71. package/src/skills/run-runtime-doctor/guide.md +12 -0
  72. package/src/skills/run-runtime-doctor/script.py +21 -0
  73. package/src/skills/run-runtime-doctor/skill.json +25 -0
  74. package/src/skills_runtime.py +347 -0
  75. package/src/tools_menu.py +3 -2
  76. package/src/tools_sessions.py +126 -0
  77. package/src/user_context.py +46 -0
  78. package/templates/nexo_helper.py +45 -0
  79. package/templates/script-template.py +44 -0
  80. package/templates/skill-script-template.py +39 -0
  81. package/templates/skill-template.md +33 -0
@@ -1,377 +1,251 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>NEXO Brain — Inbox</title>
7
- <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
8
- <script src="https://cdn.tailwindcss.com"></script>
9
- <script>tailwind.config={theme:{extend:{fontFamily:{display:['Space Grotesk','system-ui','sans-serif'],mono:['JetBrains Mono','monospace']}}}}</script>
10
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
11
- </head>
12
- <body class="bg-gray-950 text-slate-200">
13
- <div class="flex min-h-screen">
1
+ {% extends "base.html" %}
14
2
 
15
- <!-- Sidebar: 56px icon-only fixed left -->
16
- <aside class="fixed left-0 top-0 bottom-0 w-14 bg-slate-900 border-r border-slate-800 flex flex-col items-center py-4 z-50">
17
- <!-- Logo -->
18
- <a href="/" class="mb-6 flex items-center justify-center w-8 h-8">
19
- <img src="/static/nexo-logo.png" alt="NEXO" class="w-7 h-7" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
20
- <div class="w-7 h-7 rounded-lg bg-violet-600 items-center justify-center hidden">
21
- <span class="text-white font-display font-bold text-xs">N</span>
22
- </div>
23
- </a>
3
+ {% block title %}Inbox{% endblock %}
4
+ {% block page_title %}Inbox{% endblock %}
24
5
 
25
- <!-- Nav icons -->
26
- <nav class="flex flex-col items-center gap-1 flex-1">
27
- <!-- Dashboard -->
28
- <a href="/" title="Dashboard" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
29
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
30
- <path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
31
- </svg>
32
- </a>
33
- <!-- Operations -->
34
- <a href="/ops" title="Operations" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
35
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
36
- <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
37
- </svg>
38
- </a>
39
- <!-- Calendar -->
40
- <a href="/calendar" title="Calendar" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
41
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
42
- <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
43
- </svg>
44
- </a>
45
- <!-- Inbox — ACTIVE -->
46
- <a href="/inbox" title="Inbox" class="w-9 h-9 rounded-lg flex items-center justify-center bg-violet-500/10 text-violet-400 transition-colors relative">
47
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
48
- <path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
49
- </svg>
50
- <span id="inbox-badge" class="absolute -top-0.5 -right-0.5 w-4 h-4 bg-pink-500 rounded-full text-white text-xs font-bold flex items-center justify-center hidden"></span>
51
- </a>
6
+ {% block header_actions %}
7
+ <button onclick="markAllRead()" class="text-xs px-2.5 py-1 rounded-md bg-slate-800 text-slate-400 hover:bg-slate-700 transition-colors">Mark all read</button>
8
+ {% endblock %}
52
9
 
53
- <div class="w-6 h-px bg-slate-800 my-1"></div>
54
-
55
- <!-- Memory -->
56
- <a href="/memory" title="Memory" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
57
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
58
- <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
59
- </svg>
60
- </a>
61
- <!-- Graph -->
62
- <a href="/graph" title="Graph" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
63
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
64
- <path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
65
- </svg>
66
- </a>
67
- <!-- Sessions -->
68
- <a href="/sessions" title="Sessions" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
69
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
70
- <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" />
71
- </svg>
72
- </a>
73
- <!-- Somatic -->
74
- <a href="/somatic" title="Somatic" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
75
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
76
- <path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
77
- </svg>
78
- </a>
79
- <!-- Adaptive -->
80
- <a href="/adaptive" title="Adaptive" class="w-9 h-9 rounded-lg flex items-center justify-center text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors">
81
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
82
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
83
- </svg>
84
- </a>
85
- </nav>
86
-
87
- <!-- Trust score at bottom -->
88
- <div class="mt-auto flex flex-col items-center gap-0.5">
89
- <span class="text-xs uppercase tracking-widest text-slate-500 font-mono">trust</span>
90
- <span id="trust-score" class="text-xs font-mono font-medium text-violet-400">--</span>
91
- </div>
92
- </aside>
93
-
94
- <!-- Main: offset by sidebar width -->
95
- <main class="ml-14 min-h-screen bg-gray-950 text-slate-200 flex flex-col w-full">
96
- <header class="h-12 border-b border-slate-800/50 flex items-center justify-between px-6 flex-shrink-0">
97
- <h1 class="text-sm font-display font-semibold">Inbox</h1>
98
- <button onclick="markAllRead()" class="text-xs px-2.5 py-1 rounded-md bg-slate-800 text-slate-400 hover:bg-slate-700 transition-colors">Mark all read</button>
99
- </header>
100
-
101
- <div id="chat-area" class="flex-1 overflow-y-auto p-6 space-y-3">
102
- <div class="flex items-center justify-center h-full">
103
- <p class="text-slate-500 text-sm">Loading messages...</p>
104
- </div>
105
- </div>
106
-
107
- <!-- Reply indicator -->
108
- <div id="reply-bar" class="hidden border-t border-violet-500/30 bg-violet-500/5 px-6 py-2 flex-shrink-0">
109
- <div class="flex items-center gap-2 max-w-3xl mx-auto">
110
- <div class="w-0.5 h-8 bg-violet-500 rounded-full flex-shrink-0"></div>
111
- <div class="flex-1 min-w-0">
112
- <span class="text-xs text-violet-400 font-display font-medium" id="reply-author"></span>
113
- <p class="text-xs text-slate-400 truncate" id="reply-preview"></p>
114
- </div>
115
- <button onclick="cancelReply()" class="text-slate-500 hover:text-slate-300 text-lg leading-none flex-shrink-0">&times;</button>
116
- </div>
117
- </div>
10
+ {% block content %}
11
+ <div class="flex flex-col" style="height: calc(100vh - 8.5rem);">
12
+ <div id="chat-area" class="flex-1 overflow-y-auto space-y-3">
13
+ <div class="flex items-center justify-center h-full">
14
+ <p class="text-slate-500 text-sm">Loading messages...</p>
15
+ </div>
16
+ </div>
118
17
 
119
- <div class="border-t border-slate-800/50 p-4 flex-shrink-0">
120
- <div class="flex gap-3 max-w-3xl mx-auto">
121
- <textarea id="inbox-msg" placeholder="Leave a note for NEXO..." rows="2"
122
- class="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-violet-500 resize-none"
123
- onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendNote()}"></textarea>
124
- <button onclick="sendNote()" class="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-500 transition-colors self-end font-display font-medium">Send</button>
125
- </div>
18
+ <!-- Reply indicator -->
19
+ <div id="reply-bar" class="hidden border-t border-violet-500/30 bg-violet-500/5 px-6 py-2 flex-shrink-0">
20
+ <div class="flex items-center gap-2 max-w-3xl mx-auto">
21
+ <div class="w-0.5 h-8 bg-violet-500 rounded-full flex-shrink-0"></div>
22
+ <div class="flex-1 min-w-0">
23
+ <span class="text-xs text-violet-400 font-display font-medium" id="reply-author"></span>
24
+ <p class="text-xs text-slate-400 truncate" id="reply-preview"></p>
126
25
  </div>
127
- </main>
26
+ <button onclick="cancelReply()" class="text-slate-500 hover:text-slate-300 text-lg leading-none flex-shrink-0">&times;</button>
27
+ </div>
128
28
  </div>
129
29
 
130
- <!-- Toast -->
131
- <div id="toast" class="fixed bottom-6 right-6 bg-slate-800 border border-slate-700 text-slate-200 text-xs px-3 py-2 rounded-lg shadow-lg opacity-0 translate-y-2 transition-all duration-300 pointer-events-none z-50"></div>
132
-
133
- <script>
134
- let messages = [];
135
- let pollTimer = null;
136
- let replyingTo = null; // { id, direction, content }
137
-
138
- function escapeHtml(str) {
139
- return String(str)
140
- .replace(/&/g, '&amp;')
141
- .replace(/</g, '&lt;')
142
- .replace(/>/g, '&gt;')
143
- .replace(/"/g, '&quot;');
144
- }
145
-
146
- function authorName(direction) {
147
- return direction === 'to_nexo' ? 'User' : 'NEXO';
148
- }
149
-
150
- function renderMessage(msg) {
151
- const isFromUser = msg.direction === 'to_nexo';
152
- const time = new Date(msg.created_at).toLocaleString('es-ES', {
153
- day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'
154
- });
155
- const unreadDot = (!msg.read && !isFromUser)
156
- ? '<span class="inline-block w-1.5 h-1.5 rounded-full bg-violet-400 ml-1.5 align-middle"></span>'
157
- : '';
158
-
159
- // Quoted reply
160
- let quoteHtml = '';
161
- if (msg.reply_to) {
162
- const parent = messages.find(m => m.id === msg.reply_to);
163
- if (parent) {
164
- const parentAuthor = authorName(parent.direction);
165
- const parentPreview = escapeHtml((parent.content || '').slice(0, 120));
166
- quoteHtml = `
167
- <div class="flex items-start gap-2 mb-2 cursor-pointer opacity-70 hover:opacity-100 transition-opacity" onclick="scrollToMsg(${parent.id})">
168
- <div class="w-0.5 self-stretch bg-violet-500/50 rounded-full flex-shrink-0"></div>
169
- <div class="min-w-0">
170
- <span class="text-xs font-display font-medium ${parent.direction === 'to_nexo' ? 'text-slate-400' : 'text-pink-400/70'}">${parentAuthor}</span>
171
- <p class="text-xs text-slate-500 truncate">${parentPreview}</p>
172
- </div>
173
- </div>`;
174
- }
175
- }
176
-
177
- const replyBtn = `<button onclick="startReply(${msg.id})" class="text-slate-600 hover:text-violet-400 transition-colors text-xs font-mono ml-2" title="Reply">&#8617;</button>`;
178
-
179
- if (isFromUser) {
180
- return `
181
- <div class="flex justify-end" data-id="${msg.id}" id="msg-${msg.id}">
182
- <div class="max-w-lg bg-violet-600/20 border border-violet-500/20 rounded-lg px-4 py-3">
183
- ${quoteHtml}
184
- <div class="text-xs text-slate-500 mb-1 font-display">User</div>
185
- <div class="text-sm text-slate-200 leading-relaxed">${escapeHtml(msg.content).replace(/\n/g, '<br>')}</div>
186
- <div class="flex items-center justify-end mt-1">
187
- <span class="text-xs text-slate-500 font-mono">${time}</span>
188
- ${replyBtn}
189
- </div>
190
- </div>
191
- </div>`;
192
- } else {
193
- return `
194
- <div class="flex justify-start" data-id="${msg.id}" id="msg-${msg.id}">
195
- <div class="max-w-lg bg-slate-800 border border-slate-700 rounded-lg px-4 py-3">
196
- ${quoteHtml}
197
- <div class="text-xs text-pink-400 mb-1 font-display font-medium">NEXO${unreadDot}</div>
198
- <div class="text-sm text-slate-200 leading-relaxed">${escapeHtml(msg.content).replace(/\n/g, '<br>')}</div>
199
- <div class="flex items-center mt-1">
200
- <span class="text-xs text-slate-500 font-mono">${time}</span>
201
- ${replyBtn}
202
- </div>
30
+ <div class="border-t border-slate-800/50 pt-4 flex-shrink-0">
31
+ <div class="flex gap-3 max-w-3xl mx-auto">
32
+ <textarea id="inbox-msg" placeholder="Leave a note for NEXO..." rows="2"
33
+ class="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-violet-500 resize-none"
34
+ onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendNote()}"></textarea>
35
+ <button onclick="sendNote()" class="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-500 transition-colors self-end font-display font-medium">Send</button>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ {% endblock %}
40
+
41
+ {% block scripts %}
42
+ <script>
43
+ let messages = [];
44
+ let pollTimer = null;
45
+ let replyingTo = null;
46
+
47
+ function inboxEscapeHtml(str) {
48
+ return String(str)
49
+ .replace(/&/g, '&amp;')
50
+ .replace(/</g, '&lt;')
51
+ .replace(/>/g, '&gt;')
52
+ .replace(/"/g, '&quot;');
53
+ }
54
+
55
+ function authorName(direction) {
56
+ return direction === 'to_nexo' ? 'User' : 'NEXO';
57
+ }
58
+
59
+ function renderMessage(msg) {
60
+ const isFromUser = msg.direction === 'to_nexo';
61
+ const time = new Date(msg.created_at).toLocaleString('es-ES', {
62
+ day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'
63
+ });
64
+ const unreadDot = (!msg.read && !isFromUser)
65
+ ? '<span class="inline-block w-1.5 h-1.5 rounded-full bg-violet-400 ml-1.5 align-middle"></span>'
66
+ : '';
67
+
68
+ let quoteHtml = '';
69
+ if (msg.reply_to) {
70
+ const parent = messages.find(m => m.id === msg.reply_to);
71
+ if (parent) {
72
+ const parentAuthor = authorName(parent.direction);
73
+ const parentPreview = inboxEscapeHtml((parent.content || '').slice(0, 120));
74
+ quoteHtml = `
75
+ <div class="flex items-start gap-2 mb-2 cursor-pointer opacity-70 hover:opacity-100 transition-opacity" onclick="scrollToMsg(${parent.id})">
76
+ <div class="w-0.5 self-stretch bg-violet-500/50 rounded-full flex-shrink-0"></div>
77
+ <div class="min-w-0">
78
+ <span class="text-xs font-display font-medium ${parent.direction === 'to_nexo' ? 'text-slate-400' : 'text-pink-400/70'}">${parentAuthor}</span>
79
+ <p class="text-xs text-slate-500 truncate">${parentPreview}</p>
203
80
  </div>
204
81
  </div>`;
205
82
  }
206
83
  }
207
84
 
208
- function startReply(msgId) {
209
- const msg = messages.find(m => m.id === msgId);
210
- if (!msg) return;
211
- replyingTo = msg;
212
- document.getElementById('reply-bar').classList.remove('hidden');
213
- document.getElementById('reply-author').textContent = authorName(msg.direction);
214
- document.getElementById('reply-preview').textContent = (msg.content || '').slice(0, 150);
215
- document.getElementById('inbox-msg').focus();
85
+ const replyBtn = `<button onclick="startReply(${msg.id})" class="text-slate-600 hover:text-violet-400 transition-colors text-xs font-mono ml-2" title="Reply">&#8617;</button>`;
86
+
87
+ if (isFromUser) {
88
+ return `
89
+ <div class="flex justify-end" data-id="${msg.id}" id="msg-${msg.id}">
90
+ <div class="max-w-lg bg-violet-600/20 border border-violet-500/20 rounded-lg px-4 py-3">
91
+ ${quoteHtml}
92
+ <div class="text-xs text-slate-500 mb-1 font-display">User</div>
93
+ <div class="text-sm text-slate-200 leading-relaxed">${inboxEscapeHtml(msg.content).replace(/\n/g, '<br>')}</div>
94
+ <div class="flex items-center justify-end mt-1">
95
+ <span class="text-xs text-slate-500 font-mono">${time}</span>
96
+ ${replyBtn}
97
+ </div>
98
+ </div>
99
+ </div>`;
100
+ } else {
101
+ return `
102
+ <div class="flex justify-start" data-id="${msg.id}" id="msg-${msg.id}">
103
+ <div class="max-w-lg bg-slate-800 border border-slate-700 rounded-lg px-4 py-3">
104
+ ${quoteHtml}
105
+ <div class="text-xs text-pink-400 mb-1 font-display font-medium">NEXO${unreadDot}</div>
106
+ <div class="text-sm text-slate-200 leading-relaxed">${inboxEscapeHtml(msg.content).replace(/\n/g, '<br>')}</div>
107
+ <div class="flex items-center mt-1">
108
+ <span class="text-xs text-slate-500 font-mono">${time}</span>
109
+ ${replyBtn}
110
+ </div>
111
+ </div>
112
+ </div>`;
216
113
  }
217
-
218
- function cancelReply() {
219
- replyingTo = null;
220
- document.getElementById('reply-bar').classList.add('hidden');
114
+ }
115
+
116
+ function startReply(msgId) {
117
+ const msg = messages.find(m => m.id === msgId);
118
+ if (!msg) return;
119
+ replyingTo = msg;
120
+ document.getElementById('reply-bar').classList.remove('hidden');
121
+ document.getElementById('reply-author').textContent = authorName(msg.direction);
122
+ document.getElementById('reply-preview').textContent = (msg.content || '').slice(0, 150);
123
+ document.getElementById('inbox-msg').focus();
124
+ }
125
+
126
+ function cancelReply() {
127
+ replyingTo = null;
128
+ document.getElementById('reply-bar').classList.add('hidden');
129
+ }
130
+
131
+ function scrollToMsg(msgId) {
132
+ const el = document.getElementById('msg-' + msgId);
133
+ if (el) {
134
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
135
+ el.firstElementChild.classList.add('ring-1', 'ring-violet-500/50');
136
+ setTimeout(() => el.firstElementChild.classList.remove('ring-1', 'ring-violet-500/50'), 2000);
221
137
  }
138
+ }
222
139
 
223
- function scrollToMsg(msgId) {
224
- const el = document.getElementById('msg-' + msgId);
225
- if (el) {
226
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
227
- el.firstElementChild.classList.add('ring-1', 'ring-violet-500/50');
228
- setTimeout(() => el.firstElementChild.classList.remove('ring-1', 'ring-violet-500/50'), 2000);
229
- }
140
+ function renderMessages() {
141
+ const area = document.getElementById('chat-area');
142
+ if (messages.length === 0) {
143
+ area.innerHTML = `
144
+ <div class="flex items-center justify-center h-full">
145
+ <p class="text-slate-500 text-sm">No messages yet. Leave a note for NEXO.</p>
146
+ </div>`;
147
+ return;
230
148
  }
231
-
232
- function renderMessages() {
233
- const area = document.getElementById('chat-area');
234
- if (messages.length === 0) {
235
- area.innerHTML = `
236
- <div class="flex items-center justify-center h-full">
237
- <p class="text-slate-500 text-sm">No messages yet. Leave a note for NEXO.</p>
238
- </div>`;
239
- return;
149
+ area.innerHTML = `<div class="space-y-3">${messages.map(renderMessage).join('')}</div>`;
150
+ area.scrollTop = area.scrollHeight;
151
+ }
152
+
153
+ async function loadMessages() {
154
+ try {
155
+ const res = await fetch('/api/inbox?limit=200');
156
+ if (!res.ok) {
157
+ let detail = `HTTP ${res.status}`;
158
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
159
+ throw new Error(detail);
240
160
  }
241
- area.innerHTML = `<div class="space-y-3">${messages.map(renderMessage).join('')}</div>`;
242
- area.scrollTop = area.scrollHeight;
161
+ const data = await res.json();
162
+ messages = data.notes || [];
163
+ messages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
164
+ renderMessages();
165
+ markIncomingRead();
166
+ } catch (e) {
167
+ document.getElementById('chat-area').innerHTML =
168
+ `<div class="flex items-center justify-center h-full"><p class="text-red-400 text-sm">Error loading messages: ${inboxEscapeHtml(e.message)}</p></div>`;
243
169
  }
170
+ }
244
171
 
245
- async function loadMessages() {
172
+ async function markIncomingRead() {
173
+ const unread = messages.filter(m => m.direction === 'to_user' && !m.read);
174
+ for (const msg of unread) {
246
175
  try {
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
- }
253
- const data = await res.json();
254
- messages = data.notes || [];
255
- messages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
256
- renderMessages();
257
- markIncomingRead();
258
- updateBadge();
259
- } catch (e) {
260
- document.getElementById('chat-area').innerHTML =
261
- `<div class="flex items-center justify-center h-full"><p class="text-red-400 text-sm">Error loading messages: ${escapeHtml(e.message)}</p></div>`;
262
- }
176
+ await fetch(`/api/inbox/${msg.id}/read`, { method: 'PUT' });
177
+ msg.read = true;
178
+ } catch (e) { /* ignore */ }
263
179
  }
180
+ }
264
181
 
265
- async function markIncomingRead() {
266
- const unread = messages.filter(m => m.direction === 'to_user' && !m.read);
267
- for (const msg of unread) {
268
- try {
269
- await fetch(`/api/inbox/${msg.id}/read`, { method: 'PUT' });
270
- msg.read = true;
271
- } catch (e) { /* ignore */ }
272
- }
273
- }
182
+ async function sendNote() {
183
+ const textarea = document.getElementById('inbox-msg');
184
+ const content = textarea.value.trim();
185
+ if (!content) return;
274
186
 
275
- async function sendNote() {
276
- const textarea = document.getElementById('inbox-msg');
277
- const content = textarea.value.trim();
278
- if (!content) return;
279
-
280
- const body = { direction: 'to_nexo', content };
281
- if (replyingTo) body.reply_to = replyingTo.id;
282
-
283
- try {
284
- const res = await fetch('/api/inbox', {
285
- method: 'POST',
286
- headers: { 'Content-Type': 'application/json' },
287
- body: JSON.stringify(body)
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
- }
294
- const data = await res.json();
295
- textarea.value = '';
296
- cancelReply();
297
- messages.push({
298
- id: data.note?.id || Date.now(),
299
- direction: 'to_nexo',
300
- content,
301
- reply_to: body.reply_to || null,
302
- created_at: data.note?.created_at || new Date().toISOString(),
303
- read: true
304
- });
305
- renderMessages();
306
- showToast('Note sent');
307
- } catch (e) {
308
- showToast('Error: ' + e.message);
309
- }
310
- }
187
+ const body = { direction: 'to_nexo', content };
188
+ if (replyingTo) body.reply_to = replyingTo.id;
311
189
 
312
- async function markAllRead() {
313
- const unread = messages.filter(m => !m.read);
314
- for (const msg of unread) {
315
- try {
316
- await fetch(`/api/inbox/${msg.id}/read`, { method: 'PUT' });
317
- msg.read = true;
318
- } catch (e) { /* ignore */ }
190
+ try {
191
+ const res = await fetch('/api/inbox', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify(body)
195
+ });
196
+ if (!res.ok) {
197
+ let detail = `HTTP ${res.status}`;
198
+ try { const b = await res.json(); detail = b.error || b.detail || detail; } catch {}
199
+ throw new Error(detail);
319
200
  }
201
+ const data = await res.json();
202
+ textarea.value = '';
203
+ cancelReply();
204
+ messages.push({
205
+ id: data.note?.id || Date.now(),
206
+ direction: 'to_nexo',
207
+ content,
208
+ reply_to: body.reply_to || null,
209
+ created_at: data.note?.created_at || new Date().toISOString(),
210
+ read: true
211
+ });
320
212
  renderMessages();
321
- updateBadge();
322
- showToast('All messages marked as read');
323
- }
324
-
325
- function updateBadge() {
326
- const unreadCount = messages.filter(m => m.direction === 'to_user' && !m.read).length;
327
- const badge = document.getElementById('inbox-badge');
328
- if (unreadCount > 0) {
329
- badge.textContent = unreadCount > 9 ? '9+' : unreadCount;
330
- badge.classList.remove('hidden');
331
- } else {
332
- badge.classList.add('hidden');
333
- }
213
+ showToast('Note sent');
214
+ } catch (e) {
215
+ showToast('Error: ' + e.message);
334
216
  }
217
+ }
335
218
 
336
- async function pollNewMessages() {
219
+ async function markAllRead() {
220
+ const unread = messages.filter(m => !m.read);
221
+ for (const msg of unread) {
337
222
  try {
338
- const res = await fetch('/api/inbox?limit=200');
339
- const data = await res.json();
340
- const newMessages = data.notes || [];
341
- newMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
342
- const prevIds = messages.map(m => m.id).join(',');
343
- const newIds = newMessages.map(m => m.id).join(',');
344
- if (prevIds !== newIds || newMessages.length !== messages.length) {
345
- messages = newMessages;
346
- renderMessages();
347
- markIncomingRead();
348
- updateBadge();
349
- }
350
- } catch (e) { /* silent */ }
351
- }
352
-
353
- function showToast(text) {
354
- const toast = document.getElementById('toast');
355
- toast.textContent = text;
356
- toast.classList.remove('opacity-0', 'translate-y-2');
357
- toast.classList.add('opacity-100', 'translate-y-0');
358
- setTimeout(() => {
359
- toast.classList.remove('opacity-100', 'translate-y-0');
360
- toast.classList.add('opacity-0', 'translate-y-2');
361
- }, 2500);
223
+ await fetch(`/api/inbox/${msg.id}/read`, { method: 'PUT' });
224
+ msg.read = true;
225
+ } catch (e) { /* ignore */ }
362
226
  }
363
-
364
- // Load trust score
365
- fetch('/api/stats').then(r => r.json()).then(data => {
366
- const val = data.trust_score;
367
- if (val != null) {
368
- document.getElementById('trust-score').textContent = val.toFixed(1);
227
+ renderMessages();
228
+ showToast('All messages marked as read');
229
+ }
230
+
231
+ async function pollNewMessages() {
232
+ try {
233
+ const res = await fetch('/api/inbox?limit=200');
234
+ const data = await res.json();
235
+ const newMessages = data.notes || [];
236
+ newMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
237
+ const prevIds = messages.map(m => m.id).join(',');
238
+ const newIds = newMessages.map(m => m.id).join(',');
239
+ if (prevIds !== newIds || newMessages.length !== messages.length) {
240
+ messages = newMessages;
241
+ renderMessages();
242
+ markIncomingRead();
369
243
  }
370
- }).catch(() => {});
371
-
372
- // Initial load + polling
373
- loadMessages();
374
- pollTimer = setInterval(pollNewMessages, 10000);
375
- </script>
376
- </body>
377
- </html>
244
+ } catch (e) { /* silent */ }
245
+ }
246
+
247
+ // Initial load + polling
248
+ loadMessages();
249
+ pollTimer = setInterval(pollNewMessages, 10000);
250
+ </script>
251
+ {% endblock %}