agentgate 0.3.2 → 0.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.
@@ -0,0 +1,363 @@
1
+ import { Router } from 'express';
2
+ import { listMementos, getMementoById, deleteMemento, getMementoCounts, listApiKeys, getPendingQueueCount, listPendingMessages, getMessagingMode } from '../../lib/db.js';
3
+ import {
4
+ htmlHead,
5
+ simpleNavHeader,
6
+ socketScript,
7
+ localizeScript,
8
+ escapeHtml,
9
+ formatDate,
10
+ renderAvatar
11
+ } from './shared.js';
12
+
13
+ const router = Router();
14
+
15
+ // GET /ui/mementos - Admin mementos list
16
+ router.get('/', (req, res) => {
17
+ const { agent, keyword, limit = '50', offset = '0' } = req.query;
18
+ const parsedLimit = Math.min(parseInt(limit, 10) || 50, 100);
19
+ const parsedOffset = parseInt(offset, 10) || 0;
20
+
21
+ const mementos = listMementos({
22
+ agentId: agent || undefined,
23
+ keyword: keyword || undefined,
24
+ limit: parsedLimit,
25
+ offset: parsedOffset
26
+ });
27
+
28
+ // Get all agents for filter dropdown
29
+ const agents = listApiKeys().map(k => k.name).sort();
30
+
31
+ // Get stats for dashboard
32
+ const counts = getMementoCounts();
33
+
34
+ // Get nav counts
35
+ const pendingQueueCount = getPendingQueueCount();
36
+ const messagingMode = getMessagingMode();
37
+ const pendingMessagesCount = messagingMode !== 'off' ? listPendingMessages().length : 0;
38
+
39
+ const html = `${htmlHead('Mementos', { includeSocket: true })}
40
+ <body>
41
+ <div class="container">
42
+ ${simpleNavHeader({ pendingQueueCount, pendingMessagesCount, messagingMode })}
43
+
44
+ <h2 style="margin-bottom: 16px;">🧠 Agent Mementos</h2>
45
+
46
+ <!-- Stats Bar -->
47
+ <div style="display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap;">
48
+ <div class="stat-card">
49
+ <div class="stat-value">${counts.total || 0}</div>
50
+ <div class="stat-label">Total</div>
51
+ </div>
52
+ <div class="stat-card">
53
+ <div class="stat-value">${counts.byAgent?.length || agents.length}</div>
54
+ <div class="stat-label">Agents</div>
55
+ </div>
56
+ <div class="stat-card">
57
+ <div class="stat-value">${counts.last24h || 0}</div>
58
+ <div class="stat-label">Last 24h</div>
59
+ </div>
60
+ <div style="margin-left: auto;">
61
+ <a href="/ui/mementos/export${agent || keyword ? `?agent=${encodeURIComponent(agent || '')}&keyword=${encodeURIComponent(keyword || '')}` : ''}" class="btn btn-secondary" style="display: inline-flex; align-items: center; gap: 6px;">
62
+ 📥 Export JSON
63
+ </a>
64
+ </div>
65
+ </div>
66
+
67
+ <!-- Filters -->
68
+ <form method="GET" action="/ui/mementos" style="display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center;">
69
+ <select name="agent" style="padding: 8px 12px; background: #1e1e1e; border: 1px solid #333; border-radius: 4px; color: #e0e0e0;">
70
+ <option value="">All Agents</option>
71
+ ${agents.map(a => `<option value="${escapeHtml(a)}" ${agent === a ? 'selected' : ''}>${escapeHtml(a)}</option>`).join('')}
72
+ </select>
73
+ <input type="text" name="keyword" placeholder="Filter by keyword..." value="${escapeHtml(keyword || '')}"
74
+ style="padding: 8px 12px; background: #1e1e1e; border: 1px solid #333; border-radius: 4px; color: #e0e0e0; width: 200px;">
75
+ <button type="submit" class="btn btn-primary">Filter</button>
76
+ ${agent || keyword ? '<a href="/ui/mementos" class="btn btn-secondary">Clear</a>' : ''}
77
+ </form>
78
+
79
+ <!-- Results -->
80
+ <div class="card">
81
+ ${mementos.length === 0 ? `
82
+ <div style="text-align: center; padding: 40px; color: #6b7280;">
83
+ <div style="font-size: 48px; margin-bottom: 16px;">🧠</div>
84
+ <p>No mementos found${agent || keyword ? ' matching your filters' : ''}.</p>
85
+ <p style="font-size: 12px; margin-top: 8px;">Agents can store mementos via POST /api/agents/memento</p>
86
+ </div>
87
+ ` : `
88
+ <table style="width: 100%;">
89
+ <thead>
90
+ <tr>
91
+ <th style="width: 40px;">ID</th>
92
+ <th style="width: 120px;">Agent</th>
93
+ <th style="width: 150px;">Keywords</th>
94
+ <th>Preview</th>
95
+ <th style="width: 140px;">Created</th>
96
+ <th style="width: 60px;"></th>
97
+ </tr>
98
+ </thead>
99
+ <tbody>
100
+ ${mementos.map(m => `
101
+ <tr>
102
+ <td style="font-family: monospace; color: #6b7280;">${m.id}</td>
103
+ <td>
104
+ <div style="display: flex; align-items: center; gap: 8px;">
105
+ ${renderAvatar(m.agent_id, { size: 24 })}
106
+ <span style="font-size: 13px;">${escapeHtml(m.agent_id)}</span>
107
+ </div>
108
+ </td>
109
+ <td>
110
+ <div style="display: flex; flex-wrap: wrap; gap: 4px;">
111
+ ${m.keywords.slice(0, 5).map(k => `<span class="tag">${escapeHtml(k)}</span>`).join('')}
112
+ ${m.keywords.length > 5 ? `<span class="tag" style="opacity: 0.6;">+${m.keywords.length - 5}</span>` : ''}
113
+ </div>
114
+ </td>
115
+ <td style="font-size: 13px; color: #9ca3af; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
116
+ ${escapeHtml(m.preview)}
117
+ </td>
118
+ <td style="font-size: 12px;">${formatDate(m.created_at)}</td>
119
+ <td style="white-space: nowrap;">
120
+ <a href="/ui/mementos/${m.id}" class="btn btn-secondary" style="padding: 4px 8px; font-size: 12px;">View</a>
121
+ <button onclick="deleteMemento(${m.id})" class="btn btn-danger" style="padding: 4px 8px; font-size: 12px; margin-left: 4px;">×</button>
122
+ </td>
123
+ </tr>
124
+ `).join('')}
125
+ </tbody>
126
+ </table>
127
+
128
+ <!-- Pagination -->
129
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #333;">
130
+ <span style="color: #6b7280; font-size: 13px;">
131
+ Showing ${parsedOffset + 1}-${parsedOffset + mementos.length} mementos
132
+ </span>
133
+ <div style="display: flex; gap: 8px;">
134
+ ${parsedOffset > 0 ? `
135
+ <a href="/ui/mementos?${new URLSearchParams({ ...(agent && { agent }), ...(keyword && { keyword }), limit: parsedLimit, offset: Math.max(0, parsedOffset - parsedLimit) })}" class="btn btn-secondary">← Previous</a>
136
+ ` : ''}
137
+ ${mementos.length === parsedLimit ? `
138
+ <a href="/ui/mementos?${new URLSearchParams({ ...(agent && { agent }), ...(keyword && { keyword }), limit: parsedLimit, offset: parsedOffset + parsedLimit })}" class="btn btn-secondary">Next →</a>
139
+ ` : ''}
140
+ </div>
141
+ </div>
142
+ `}
143
+ </div>
144
+ </div>
145
+ ${socketScript()}
146
+ ${localizeScript()}
147
+ <style>
148
+ .tag {
149
+ background: rgba(99, 102, 241, 0.2);
150
+ color: #a5b4fc;
151
+ padding: 2px 6px;
152
+ border-radius: 4px;
153
+ font-size: 11px;
154
+ }
155
+ .stat-card {
156
+ background: rgba(99, 102, 241, 0.1);
157
+ border: 1px solid rgba(99, 102, 241, 0.2);
158
+ border-radius: 8px;
159
+ padding: 12px 20px;
160
+ text-align: center;
161
+ }
162
+ .stat-value {
163
+ font-size: 24px;
164
+ font-weight: 700;
165
+ color: #818cf8;
166
+ }
167
+ .stat-label {
168
+ font-size: 11px;
169
+ color: #9ca3af;
170
+ text-transform: uppercase;
171
+ letter-spacing: 0.05em;
172
+ }
173
+ .btn-danger {
174
+ background: rgba(239, 68, 68, 0.1);
175
+ border: 1px solid rgba(239, 68, 68, 0.3);
176
+ color: #f87171;
177
+ }
178
+ .btn-danger:hover {
179
+ background: rgba(239, 68, 68, 0.2);
180
+ }
181
+ </style>
182
+ <script>
183
+ async function deleteMemento(id) {
184
+ if (!confirm('Delete this memento? This cannot be undone.')) return;
185
+ try {
186
+ const res = await fetch('/ui/mementos/' + id + '/delete', {
187
+ method: 'POST',
188
+ headers: { 'Accept': 'application/json' }
189
+ });
190
+ const data = await res.json();
191
+ if (data.success) {
192
+ window.location.reload();
193
+ } else {
194
+ alert(data.error || 'Failed to delete');
195
+ }
196
+ } catch (err) {
197
+ alert('Error: ' + err.message);
198
+ }
199
+ }
200
+ </script>
201
+ </body>
202
+ </html>`;
203
+
204
+ res.send(html);
205
+ });
206
+
207
+ // GET /ui/mementos/export - Export mementos as JSON
208
+ router.get('/export', (req, res) => {
209
+ const { agent, keyword } = req.query;
210
+ const mementos = listMementos({
211
+ agentId: agent || undefined,
212
+ keyword: keyword || undefined,
213
+ limit: 10000
214
+ });
215
+
216
+ res.setHeader('Content-Type', 'application/json');
217
+ res.setHeader('Content-Disposition', 'attachment; filename="mementos-export.json"');
218
+ res.json(mementos);
219
+ });
220
+
221
+ // POST /ui/mementos/:id/delete - Delete a memento
222
+ router.post('/:id/delete', (req, res) => {
223
+ const { id } = req.params;
224
+ const wantsJson = req.headers.accept?.includes('application/json');
225
+
226
+ const memento = getMementoById(parseInt(id, 10));
227
+ if (!memento) {
228
+ return wantsJson
229
+ ? res.status(404).json({ error: 'Memento not found' })
230
+ : res.status(404).send('Memento not found');
231
+ }
232
+
233
+ deleteMemento(parseInt(id, 10));
234
+
235
+ if (wantsJson) {
236
+ return res.json({ success: true });
237
+ }
238
+ res.redirect('/ui/mementos');
239
+ });
240
+
241
+ // GET /ui/mementos/:id - View single memento
242
+ router.get('/:id', (req, res) => {
243
+ const { id } = req.params;
244
+ const memento = getMementoById(parseInt(id, 10));
245
+
246
+ if (!memento) {
247
+ return res.status(404).send(`${htmlHead('Memento Not Found')}
248
+ <body>
249
+ <div class="container">
250
+ <h1>Memento Not Found</h1>
251
+ <p>No memento with ID ${escapeHtml(id)} exists.</p>
252
+ <a href="/ui/mementos" class="btn btn-primary">← Back to Mementos</a>
253
+ </div>
254
+ </body>
255
+ </html>`);
256
+ }
257
+
258
+ // Get nav counts
259
+ const pendingQueueCount = getPendingQueueCount();
260
+ const messagingMode = getMessagingMode();
261
+ const pendingMessagesCount = messagingMode !== 'off' ? listPendingMessages().length : 0;
262
+
263
+ const html = `${htmlHead(`Memento #${memento.id}`, { includeSocket: true })}
264
+ <body>
265
+ <div class="container">
266
+ ${simpleNavHeader({ pendingQueueCount, pendingMessagesCount, messagingMode })}
267
+
268
+ <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
269
+ <a href="/ui/mementos" class="btn btn-secondary">← Back</a>
270
+ <h2 style="margin: 0; flex: 1;">Memento #${memento.id}</h2>
271
+ <button onclick="deleteMemento(${memento.id})" class="btn btn-danger">Delete</button>
272
+ </div>
273
+
274
+ <div class="card" style="margin-bottom: 16px;">
275
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 16px;">
276
+ <div>
277
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px;">Agent</div>
278
+ <div style="display: flex; align-items: center; gap: 8px;">
279
+ ${renderAvatar(memento.agent_id, { size: 28 })}
280
+ <span>${escapeHtml(memento.agent_id)}</span>
281
+ </div>
282
+ </div>
283
+ <div>
284
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px;">Created</div>
285
+ <div>${formatDate(memento.created_at)}</div>
286
+ </div>
287
+ ${memento.model ? `
288
+ <div>
289
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px;">Model</div>
290
+ <div style="font-family: monospace; font-size: 13px;">${escapeHtml(memento.model)}</div>
291
+ </div>
292
+ ` : ''}
293
+ ${memento.role ? `
294
+ <div>
295
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px;">Role</div>
296
+ <div>${escapeHtml(memento.role)}</div>
297
+ </div>
298
+ ` : ''}
299
+ </div>
300
+
301
+ <div style="margin-bottom: 16px;">
302
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 8px;">Keywords</div>
303
+ <div style="display: flex; flex-wrap: wrap; gap: 6px;">
304
+ ${memento.keywords.map(k => `
305
+ <a href="/ui/mementos?keyword=${encodeURIComponent(k)}" class="tag" style="text-decoration: none;">${escapeHtml(k)}</a>
306
+ `).join('')}
307
+ </div>
308
+ </div>
309
+
310
+ <div>
311
+ <div style="color: #6b7280; font-size: 12px; margin-bottom: 8px;">Content</div>
312
+ <pre style="background: #0d0d0d; padding: 16px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.5;">${escapeHtml(memento.content)}</pre>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ ${socketScript()}
317
+ ${localizeScript()}
318
+ <style>
319
+ .tag {
320
+ background: rgba(99, 102, 241, 0.2);
321
+ color: #a5b4fc;
322
+ padding: 4px 10px;
323
+ border-radius: 4px;
324
+ font-size: 12px;
325
+ }
326
+ .tag:hover {
327
+ background: rgba(99, 102, 241, 0.3);
328
+ }
329
+ .btn-danger {
330
+ background: rgba(239, 68, 68, 0.1);
331
+ border: 1px solid rgba(239, 68, 68, 0.3);
332
+ color: #f87171;
333
+ }
334
+ .btn-danger:hover {
335
+ background: rgba(239, 68, 68, 0.2);
336
+ }
337
+ </style>
338
+ <script>
339
+ async function deleteMemento(id) {
340
+ if (!confirm('Delete this memento? This cannot be undone.')) return;
341
+ try {
342
+ const res = await fetch('/ui/mementos/' + id + '/delete', {
343
+ method: 'POST',
344
+ headers: { 'Accept': 'application/json' }
345
+ });
346
+ const data = await res.json();
347
+ if (data.success) {
348
+ window.location.href = '/ui/mementos';
349
+ } else {
350
+ alert(data.error || 'Failed to delete');
351
+ }
352
+ } catch (err) {
353
+ alert('Error: ' + err.message);
354
+ }
355
+ }
356
+ </script>
357
+ </body>
358
+ </html>`;
359
+
360
+ res.send(html);
361
+ });
362
+
363
+ export default router;