prism-mcp-server 5.2.0 → 5.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.
- package/README.md +308 -218
- package/dist/backgroundScheduler.js +327 -0
- package/dist/config.js +29 -0
- package/dist/dashboard/server.js +246 -0
- package/dist/dashboard/ui.js +216 -6
- package/dist/hivemindWatchdog.js +206 -0
- package/dist/lifecycle.js +59 -4
- package/dist/scholar/freeSearch.js +78 -0
- package/dist/scholar/webScholar.js +258 -0
- package/dist/sdm/sdmDecoder.js +75 -0
- package/dist/sdm/sdmEngine.js +158 -0
- package/dist/server.js +173 -11
- package/dist/storage/sqlite.js +298 -47
- package/dist/storage/supabase.js +114 -1
- package/dist/tools/agentRegistryDefinitions.js +11 -4
- package/dist/tools/agentRegistryHandlers.js +23 -5
- package/dist/tools/index.js +2 -2
- package/dist/tools/sessionMemoryDefinitions.js +46 -1
- package/dist/tools/sessionMemoryHandlers.js +210 -38
- package/dist/utils/briefing.js +1 -1
- package/dist/utils/crdtMerge.js +152 -0
- package/dist/utils/healthCheck.js +15 -0
- package/dist/utils/llm/adapters/gemini.js +3 -3
- package/package.json +9 -2
package/dist/dashboard/ui.js
CHANGED
|
@@ -18,8 +18,12 @@ export function renderDashboardHTML(version) {
|
|
|
18
18
|
<html lang="en">
|
|
19
19
|
<head>
|
|
20
20
|
<meta charset="UTF-8">
|
|
21
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
22
22
|
<title>Prism MCP — Mind Palace</title>
|
|
23
|
+
<!-- PWA Metadata -->
|
|
24
|
+
<link rel="manifest" href="/manifest.json">
|
|
25
|
+
<meta name="theme-color" content="#0a0e1a">
|
|
26
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
23
27
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
24
28
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
25
29
|
<!-- Vis.js for Neural Graph (v2.3.0) -->
|
|
@@ -175,6 +179,32 @@ export function renderDashboardHTML(version) {
|
|
|
175
179
|
.grid-main { grid-template-columns: 1fr 2fr; }
|
|
176
180
|
@media (max-width: 900px) { .grid-main { grid-template-columns: 1fr; } }
|
|
177
181
|
|
|
182
|
+
/* ─── PWA Mobile Overrides (v5.4) ─── */
|
|
183
|
+
@media (max-width: 600px) {
|
|
184
|
+
.container { padding: 1rem; }
|
|
185
|
+
header { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
|
186
|
+
.selector { width: 100%; flex-wrap: wrap; }
|
|
187
|
+
.selector select { flex: 1; min-width: 0; }
|
|
188
|
+
|
|
189
|
+
/* Swipeable Columns via CSS Scroll Snap */
|
|
190
|
+
.grid-main {
|
|
191
|
+
display: flex;
|
|
192
|
+
overflow-x: auto;
|
|
193
|
+
scroll-snap-type: x mandatory;
|
|
194
|
+
-webkit-overflow-scrolling: touch;
|
|
195
|
+
gap: 0;
|
|
196
|
+
margin: 0 -1rem; /* bleed to edge */
|
|
197
|
+
padding-bottom: 1rem;
|
|
198
|
+
scrollbar-width: none; /* Hide scrollbar Firefox */
|
|
199
|
+
}
|
|
200
|
+
.grid-main::-webkit-scrollbar { display: none; } /* Hide scrollbar Chrome/Safari */
|
|
201
|
+
.grid-main > .grid {
|
|
202
|
+
flex: 0 0 100%;
|
|
203
|
+
scroll-snap-align: start;
|
|
204
|
+
padding: 0 1rem;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
/* ─── State Panel ─── */
|
|
179
209
|
.summary-text { color: var(--text-secondary); font-size: 0.9rem; line-height: 1.7; margin-bottom: 1rem; }
|
|
180
210
|
.todo-list { list-style: none; padding: 0; }
|
|
@@ -443,7 +473,14 @@ export function renderDashboardHTML(version) {
|
|
|
443
473
|
width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green);
|
|
444
474
|
flex-shrink: 0; animation: pulseDot 2s ease-in-out infinite;
|
|
445
475
|
}
|
|
476
|
+
.pulse-dot.looping {
|
|
477
|
+
animation: spinDot 1s linear infinite;
|
|
478
|
+
background: #a855f7 !important;
|
|
479
|
+
border-radius: 2px;
|
|
480
|
+
}
|
|
446
481
|
@keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
482
|
+
@keyframes spinDot { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
483
|
+
.team-status { font-size: 0.8rem; flex-shrink: 0; }
|
|
447
484
|
|
|
448
485
|
/* ─── Memory Analytics (v3.1) ─── */
|
|
449
486
|
.sparkline {
|
|
@@ -733,6 +770,20 @@ export function renderDashboardHTML(version) {
|
|
|
733
770
|
</li>
|
|
734
771
|
</ul>
|
|
735
772
|
</div>
|
|
773
|
+
|
|
774
|
+
<!-- Background Scheduler Status (v5.4) -->
|
|
775
|
+
<div class="card" id="schedulerCard">
|
|
776
|
+
<div class="card-title" style="display:flex;align-items:center;">
|
|
777
|
+
<span class="dot" style="background:var(--accent-amber, #f59e0b)"></span>
|
|
778
|
+
Background Scheduler ⏰
|
|
779
|
+
<div style="flex:1"></div>
|
|
780
|
+
<button id="scholarBtn" onclick="triggerWebScholar()" class="lc-btn compact" style="margin-right:0.5rem">🧠 Scholar (Run)</button>
|
|
781
|
+
<button onclick="loadSchedulerStatus()" class="refresh-btn">↻</button>
|
|
782
|
+
</div>
|
|
783
|
+
<div id="schedulerContent" style="font-size:0.8rem;color:var(--text-muted)">
|
|
784
|
+
Loading scheduler status...
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
736
787
|
</div>
|
|
737
788
|
</div>
|
|
738
789
|
|
|
@@ -1299,7 +1350,8 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
1299
1350
|
var t = healthData.totals || {};
|
|
1300
1351
|
healthSummary.textContent = (t.activeEntries || 0) + ' entries · ' +
|
|
1301
1352
|
(t.handoffs || 0) + ' handoffs · ' +
|
|
1302
|
-
(t.rollups || 0) + ' rollups'
|
|
1353
|
+
(t.rollups || 0) + ' rollups' +
|
|
1354
|
+
(t.crdtMerges ? ' · 🔄 ' + t.crdtMerges + ' merges' : '');
|
|
1303
1355
|
|
|
1304
1356
|
// Issue rows
|
|
1305
1357
|
var issues = healthData.issues || [];
|
|
@@ -2148,7 +2200,9 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
2148
2200
|
setTimeout(function() { toast.classList.remove('show'); }, 2000);
|
|
2149
2201
|
}
|
|
2150
2202
|
|
|
2151
|
-
// ─── Hivemind Radar (
|
|
2203
|
+
// ─── Hivemind Radar (v5.3 — Health Watchdog) ───
|
|
2204
|
+
var hivemindRefreshTimer = null;
|
|
2205
|
+
|
|
2152
2206
|
async function loadTeam() {
|
|
2153
2207
|
var project = document.getElementById('projectSelect').value;
|
|
2154
2208
|
if (!project) return;
|
|
@@ -2160,15 +2214,36 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
2160
2214
|
var list = document.getElementById('teamList');
|
|
2161
2215
|
if (team.length > 0) {
|
|
2162
2216
|
var roleIcons = {dev:'🛠️',qa:'🔍',pm:'📋',lead:'🏗️',security:'🔒',ux:'🎨',cmo:'📢'};
|
|
2217
|
+
var statusColors = {
|
|
2218
|
+
active: '#10b981', stale: '#f59e0b', frozen: '#ef4444',
|
|
2219
|
+
overdue: '#f97316', looping: '#a855f7', idle: '#64748b', shutdown: '#374151'
|
|
2220
|
+
};
|
|
2221
|
+
var statusLabels = {
|
|
2222
|
+
active: '🟢', stale: '🟡', frozen: '🔴',
|
|
2223
|
+
overdue: '⏰', looping: '🔄', idle: '💤', shutdown: '⚫'
|
|
2224
|
+
};
|
|
2163
2225
|
list.innerHTML = team.map(function(a) {
|
|
2164
2226
|
var icon = roleIcons[a.role] || '🤖';
|
|
2165
2227
|
var ago = a.last_heartbeat ? timeAgo(a.last_heartbeat) : '?';
|
|
2228
|
+
var dotColor = statusColors[a.status] || '#64748b';
|
|
2229
|
+
var statusIcon = statusLabels[a.status] || '❓';
|
|
2230
|
+
var loopBadge = (a.loop_count && a.loop_count >= 3)
|
|
2231
|
+
? ' <span style="color:#a855f7;font-size:0.75rem">🔄 ' + a.loop_count + 'x</span>'
|
|
2232
|
+
: '';
|
|
2233
|
+
var dotClass = 'pulse-dot' + (a.status === 'looping' ? ' looping' : '');
|
|
2166
2234
|
return '<li class="team-item">' +
|
|
2167
|
-
'<span class="
|
|
2235
|
+
'<span class="' + dotClass + '" style="background:' + dotColor + '"></span>' +
|
|
2168
2236
|
'<span class="team-role">' + icon + ' ' + escapeHtml(a.role) + '</span>' +
|
|
2169
|
-
'<span class="team-
|
|
2237
|
+
'<span class="team-status" title="' + (a.status || 'active') + '">' + statusIcon + '</span>' +
|
|
2238
|
+
'<span class="team-task">' + escapeHtml(a.current_task || 'idle') + loopBadge + '</span>' +
|
|
2170
2239
|
'<span class="team-heartbeat">' + ago + '</span></li>';
|
|
2171
2240
|
}).join('');
|
|
2241
|
+
var healthyCt = team.filter(function(a){ return a.status === 'active' || a.status === 'idle'; }).length;
|
|
2242
|
+
var warnCt = team.length - healthyCt;
|
|
2243
|
+
var summary = team.length + ' agent(s)';
|
|
2244
|
+
if (warnCt > 0) summary += ' | ⚠️ ' + warnCt + ' need attention';
|
|
2245
|
+
summary += ' | 🐝 Watchdog active';
|
|
2246
|
+
list.innerHTML += '<li style="color:var(--text-muted);font-size:0.75rem;text-align:center;padding:0.5rem;border-top:1px solid var(--border)">' + summary + '</li>';
|
|
2172
2247
|
card.style.display = 'block';
|
|
2173
2248
|
} else {
|
|
2174
2249
|
list.innerHTML = '<li style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1rem">No active agents on this project.</li>';
|
|
@@ -2179,6 +2254,105 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
2179
2254
|
}
|
|
2180
2255
|
}
|
|
2181
2256
|
|
|
2257
|
+
// v5.3: Auto-refresh Hivemind Radar every 15s
|
|
2258
|
+
function startHivemindRefresh() {
|
|
2259
|
+
stopHivemindRefresh();
|
|
2260
|
+
hivemindRefreshTimer = setInterval(loadTeam, 15000);
|
|
2261
|
+
}
|
|
2262
|
+
function stopHivemindRefresh() {
|
|
2263
|
+
if (hivemindRefreshTimer) { clearInterval(hivemindRefreshTimer); hivemindRefreshTimer = null; }
|
|
2264
|
+
}
|
|
2265
|
+
if (document.getElementById('hivemindCard')) {
|
|
2266
|
+
startHivemindRefresh();
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// ─── Background Scheduler Status (v5.4) ───
|
|
2270
|
+
async function loadSchedulerStatus() {
|
|
2271
|
+
var el = document.getElementById('schedulerContent');
|
|
2272
|
+
if (!el) return;
|
|
2273
|
+
try {
|
|
2274
|
+
var res = await fetch('/api/scheduler');
|
|
2275
|
+
var data = await res.json();
|
|
2276
|
+
if (!data.running) {
|
|
2277
|
+
var offHtml = '<div style="color:var(--text-muted)">⏸ Scheduler not running. Set <code style="font-family:var(--font-mono);font-size:0.75rem">PRISM_SCHEDULER_ENABLED=true</code> to enable.</div>';
|
|
2278
|
+
offHtml += '<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-glass); font-size: 0.85em; color: var(--text-muted);">' +
|
|
2279
|
+
'<strong>Web Scholar:</strong> ' + (data.scholarRunning ? '🟢 Enabled' : '🔴 Disabled') +
|
|
2280
|
+
(data.scholarIntervalMs ? ' (every ' + Math.round(data.scholarIntervalMs / 60000) + 'm)' : '') +
|
|
2281
|
+
'</div>';
|
|
2282
|
+
el.innerHTML = offHtml;
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
var intervalH = Math.round(data.intervalMs / 3600000);
|
|
2286
|
+
var parts = ['<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem">'];
|
|
2287
|
+
parts.push('<span style="color:var(--accent-green)">🟢 Running</span>');
|
|
2288
|
+
parts.push('<span>Interval: <strong>' + intervalH + 'h</strong></span>');
|
|
2289
|
+
if (data.startedAt) {
|
|
2290
|
+
parts.push('<span>Started: ' + formatDate(data.startedAt) + '</span>');
|
|
2291
|
+
}
|
|
2292
|
+
parts.push('</div>');
|
|
2293
|
+
|
|
2294
|
+
if (data.lastSweep) {
|
|
2295
|
+
var ls = data.lastSweep;
|
|
2296
|
+
parts.push('<div style="border-top:1px solid var(--border-glass);padding-top:0.5rem;margin-top:0.25rem">');
|
|
2297
|
+
parts.push('<div style="margin-bottom:0.3rem;color:var(--text-secondary)">Last sweep: ' + formatDate(ls.completedAt) + ' (' + ls.durationMs + 'ms)</div>');
|
|
2298
|
+
parts.push('<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:0.3rem;font-size:0.75rem">');
|
|
2299
|
+
var t = ls.tasks;
|
|
2300
|
+
if (t.ttlSweep.ran) {
|
|
2301
|
+
parts.push('<div>🗓️ TTL: ' + t.ttlSweep.totalExpired + ' expired (' + t.ttlSweep.projectsSwept + ' projects)</div>');
|
|
2302
|
+
}
|
|
2303
|
+
if (t.importanceDecay.ran) {
|
|
2304
|
+
parts.push('<div>📉 Decay: ' + t.importanceDecay.projectsDecayed + ' projects</div>');
|
|
2305
|
+
}
|
|
2306
|
+
if (t.compaction.ran) {
|
|
2307
|
+
parts.push('<div>🧹 Compact: ' + t.compaction.projectsCompacted + ' compacted</div>');
|
|
2308
|
+
}
|
|
2309
|
+
if (t.deepPurge.ran) {
|
|
2310
|
+
var bytes = t.deepPurge.reclaimedBytes;
|
|
2311
|
+
var bytesStr = bytes > 1048576 ? (bytes / 1048576).toFixed(1) + 'MB' : bytes > 1024 ? (bytes / 1024).toFixed(1) + 'KB' : bytes + 'B';
|
|
2312
|
+
parts.push('<div>💾 Purge: ' + t.deepPurge.purged + ' entries (' + bytesStr + ' freed)</div>');
|
|
2313
|
+
}
|
|
2314
|
+
parts.push('</div>');
|
|
2315
|
+
// Show errors if any
|
|
2316
|
+
var errors = [t.ttlSweep.error, t.importanceDecay.error, t.compaction.error, t.deepPurge.error].filter(Boolean);
|
|
2317
|
+
if (errors.length > 0) {
|
|
2318
|
+
parts.push('<div style="color:var(--accent-rose);margin-top:0.3rem;font-size:0.7rem">⚠️ ' + errors.join(' | ') + '</div>');
|
|
2319
|
+
}
|
|
2320
|
+
parts.push('</div>');
|
|
2321
|
+
} else {
|
|
2322
|
+
parts.push('<div style="color:var(--text-muted)">No sweep completed yet. First sweep runs 5s after start.</div>');
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
var scholarStatusHtml = '<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-glass); font-size: 0.85em; color: var(--text-muted);">' +
|
|
2326
|
+
'<strong>Web Scholar:</strong> ' + (data.scholarRunning ? '🟢 Enabled' : '🔴 Disabled') +
|
|
2327
|
+
(data.scholarIntervalMs ? ' (every ' + Math.round(data.scholarIntervalMs / 60000) + 'm)' : '') +
|
|
2328
|
+
'</div>';
|
|
2329
|
+
parts.push(scholarStatusHtml);
|
|
2330
|
+
|
|
2331
|
+
el.innerHTML = parts.join('');
|
|
2332
|
+
} catch(e) {
|
|
2333
|
+
el.innerHTML = '<div style="color:var(--text-muted)">Scheduler status unavailable</div>';
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
async function triggerWebScholar() {
|
|
2338
|
+
var btn = document.getElementById('scholarBtn');
|
|
2339
|
+
if (btn) { btn.disabled = true; btn.textContent = '🔄 Triggering...'; }
|
|
2340
|
+
try {
|
|
2341
|
+
var res = await fetch('/api/scholar/trigger', { method: 'POST' });
|
|
2342
|
+
var data = await res.json();
|
|
2343
|
+
showFixedToast(data.message || (data.ok ? 'Scholar triggered.' : 'Scholar failed.'), data.ok);
|
|
2344
|
+
} catch (e) {
|
|
2345
|
+
showFixedToast('Scholar trigger failed.', false);
|
|
2346
|
+
} finally {
|
|
2347
|
+
if (btn) { btn.disabled = false; btn.textContent = '🧠 Scholar (Run)'; }
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// Load scheduler status on page load
|
|
2352
|
+
loadSchedulerStatus();
|
|
2353
|
+
// Auto-refresh scheduler status every 60s
|
|
2354
|
+
setInterval(loadSchedulerStatus, 60000);
|
|
2355
|
+
|
|
2182
2356
|
function timeAgo(iso) {
|
|
2183
2357
|
var diff = Date.now() - new Date(iso).getTime();
|
|
2184
2358
|
var mins = Math.floor(diff / 60000);
|
|
@@ -2209,7 +2383,7 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
2209
2383
|
healthDot.className = 'health-dot ' + (healthData.status || 'unknown');
|
|
2210
2384
|
healthLabel.textContent = statusMap[healthData.status] || '❓ Unknown';
|
|
2211
2385
|
var t = healthData.totals || {};
|
|
2212
|
-
healthSummary.textContent = (t.activeEntries || 0) + ' entries · ' + (t.handoffs || 0) + ' handoffs · ' + (t.rollups || 0) + ' rollups';
|
|
2386
|
+
healthSummary.textContent = (t.activeEntries || 0) + ' entries · ' + (t.handoffs || 0) + ' handoffs · ' + (t.rollups || 0) + ' rollups' + (t.crdtMerges ? ' · 🔄 ' + t.crdtMerges + ' merges' : '');
|
|
2213
2387
|
var issues = healthData.issues || [];
|
|
2214
2388
|
if (issues.length > 0) {
|
|
2215
2389
|
var sevIcons = { error: '🔴', warning: '🟡', info: '🔵' };
|
|
@@ -2235,6 +2409,42 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
2235
2409
|
t.classList.add('show');
|
|
2236
2410
|
setTimeout(function() { t.classList.remove('show'); }, 3500);
|
|
2237
2411
|
}
|
|
2412
|
+
|
|
2413
|
+
// ─── PWA Service Worker Registration ───
|
|
2414
|
+
if ('serviceWorker' in navigator) {
|
|
2415
|
+
window.addEventListener('load', function() {
|
|
2416
|
+
navigator.serviceWorker.register('/sw.js').then(function(reg) {
|
|
2417
|
+
console.log('[Dashboard] Service Worker registered with scope:', reg.scope);
|
|
2418
|
+
|
|
2419
|
+
reg.addEventListener('updatefound', function() {
|
|
2420
|
+
var newWorker = reg.installing;
|
|
2421
|
+
newWorker.addEventListener('statechange', function() {
|
|
2422
|
+
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
2423
|
+
var toast = document.createElement('div');
|
|
2424
|
+
toast.style.cssText = 'position:fixed;bottom:2rem;right:2rem;background:var(--bg-glass);backdrop-filter:blur(12px);border:1px solid var(--border-glass);padding:1rem 1.5rem;border-radius:12px;display:flex;align-items:center;gap:1.5rem;z-index:9999;box-shadow:0 10px 30px rgba(0,0,0,0.5);transform:translateY(0);transition:transform 0.3s, opacity 0.3s;';
|
|
2425
|
+
toast.innerHTML = '<div><p style="font-weight:600;margin-bottom:0.25rem;color:var(--text-primary);">Update Available</p><p style="color:var(--text-secondary);font-size:0.85rem;">A new version of Prism is ready.</p></div><button style="background:linear-gradient(135deg, var(--accent-purple), var(--accent-blue));color:white;border:none;padding:0.5rem 1rem;border-radius:6px;cursor:pointer;font-weight:600;">Refresh</button>';
|
|
2426
|
+
|
|
2427
|
+
toast.querySelector('button').addEventListener('click', function() {
|
|
2428
|
+
newWorker.postMessage({ action: 'skipWaiting' });
|
|
2429
|
+
toast.style.opacity = '0';
|
|
2430
|
+
});
|
|
2431
|
+
document.body.appendChild(toast);
|
|
2432
|
+
}
|
|
2433
|
+
});
|
|
2434
|
+
});
|
|
2435
|
+
}).catch(function(err) {
|
|
2436
|
+
console.error('[Dashboard] Service Worker registration failed:', err);
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
var refreshing = false;
|
|
2440
|
+
navigator.serviceWorker.addEventListener('controllerchange', function() {
|
|
2441
|
+
if (!refreshing) {
|
|
2442
|
+
refreshing = true;
|
|
2443
|
+
window.location.reload();
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2238
2448
|
</script>
|
|
2239
2449
|
</body>
|
|
2240
2450
|
</html>`;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hivemind Watchdog (v5.3) — Active Agent Health Monitoring
|
|
3
|
+
*
|
|
4
|
+
* Server-side health monitor for multi-agent coordination.
|
|
5
|
+
* Runs every WATCHDOG_INTERVAL_MS when PRISM_ENABLE_HIVEMIND=true.
|
|
6
|
+
*
|
|
7
|
+
* State Transitions (per sweep):
|
|
8
|
+
* ACTIVE → STALE (no heartbeat for staleThresholdMin)
|
|
9
|
+
* STALE → FROZEN (no heartbeat for frozenThresholdMin)
|
|
10
|
+
* FROZEN → [pruned] (no heartbeat for offlineThresholdMin)
|
|
11
|
+
* ACTIVE → OVERDUE (task_start + expected_duration exceeded)
|
|
12
|
+
* ACTIVE → LOOPING (loop_count >= loopThreshold, set by heartbeatAgent)
|
|
13
|
+
*
|
|
14
|
+
* Alerts are queued in-memory and drained by the tool dispatch
|
|
15
|
+
* handler in server.ts, which APPENDS them to the tool response
|
|
16
|
+
* content so the LLM actually reads the warning.
|
|
17
|
+
*
|
|
18
|
+
* Architecture:
|
|
19
|
+
* - Zero dependencies on MCP Server object (pure business logic)
|
|
20
|
+
* - Storage accessed via getStorage() singleton
|
|
21
|
+
* - Alerts are fire-and-forget in-memory Map (no persistence needed)
|
|
22
|
+
* - Sweep is non-blocking: errors are caught and logged, never crash
|
|
23
|
+
*/
|
|
24
|
+
import { getStorage } from "./storage/index.js";
|
|
25
|
+
import { PRISM_USER_ID } from "./config.js";
|
|
26
|
+
export const DEFAULT_WATCHDOG_CONFIG = {
|
|
27
|
+
intervalMs: 60_000,
|
|
28
|
+
staleThresholdMin: 5,
|
|
29
|
+
frozenThresholdMin: 15,
|
|
30
|
+
offlineThresholdMin: 30,
|
|
31
|
+
loopThreshold: 5,
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Pending alerts — keyed by "project:role:status" to deduplicate.
|
|
35
|
+
* Only one alert per agent per status is kept until drained.
|
|
36
|
+
*/
|
|
37
|
+
const pendingAlerts = new Map();
|
|
38
|
+
/**
|
|
39
|
+
* Drain all pending alerts for a project.
|
|
40
|
+
* Called by server.ts in the CallToolRequestSchema handler
|
|
41
|
+
* to inject warnings into the tool response content.
|
|
42
|
+
*
|
|
43
|
+
* Returns and clears all alerts for the given project.
|
|
44
|
+
*/
|
|
45
|
+
export function drainAlerts(project) {
|
|
46
|
+
const alerts = [];
|
|
47
|
+
for (const [key, alert] of pendingAlerts.entries()) {
|
|
48
|
+
if (alert.project === project) {
|
|
49
|
+
alerts.push(alert);
|
|
50
|
+
pendingAlerts.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return alerts;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get count of pending alerts (for testing/debugging).
|
|
57
|
+
*/
|
|
58
|
+
export function getPendingAlertCount() {
|
|
59
|
+
return pendingAlerts.size;
|
|
60
|
+
}
|
|
61
|
+
// ─── Watchdog Lifecycle ──────────────────────────────────────
|
|
62
|
+
let watchdogInterval = null;
|
|
63
|
+
/**
|
|
64
|
+
* Start the watchdog sweep interval.
|
|
65
|
+
* Returns a cleanup function that stops the interval.
|
|
66
|
+
*
|
|
67
|
+
* @param config - Override defaults for testing or production tuning
|
|
68
|
+
*/
|
|
69
|
+
export function startWatchdog(config) {
|
|
70
|
+
const cfg = { ...DEFAULT_WATCHDOG_CONFIG, ...config };
|
|
71
|
+
if (watchdogInterval) {
|
|
72
|
+
clearInterval(watchdogInterval);
|
|
73
|
+
}
|
|
74
|
+
watchdogInterval = setInterval(() => {
|
|
75
|
+
runWatchdogSweep(cfg).catch(err => {
|
|
76
|
+
console.error(`[Watchdog] Sweep error (non-fatal): ${err}`);
|
|
77
|
+
});
|
|
78
|
+
}, cfg.intervalMs);
|
|
79
|
+
// Run an immediate first sweep
|
|
80
|
+
runWatchdogSweep(cfg).catch(err => {
|
|
81
|
+
console.error(`[Watchdog] Initial sweep error (non-fatal): ${err}`);
|
|
82
|
+
});
|
|
83
|
+
console.error(`[Watchdog] 🐝 Started (interval=${cfg.intervalMs}ms, stale=${cfg.staleThresholdMin}m, frozen=${cfg.frozenThresholdMin}m)`);
|
|
84
|
+
return () => {
|
|
85
|
+
if (watchdogInterval) {
|
|
86
|
+
clearInterval(watchdogInterval);
|
|
87
|
+
watchdogInterval = null;
|
|
88
|
+
console.error("[Watchdog] Stopped");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ─── Core Sweep Logic ────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* Single watchdog sweep — exported for testing.
|
|
95
|
+
*
|
|
96
|
+
* 1. Fetches ALL registered agents for the user
|
|
97
|
+
* 2. Computes time since last heartbeat for each
|
|
98
|
+
* 3. Applies state transition rules
|
|
99
|
+
* 4. Checks OVERDUE (task_start + expected_duration exceeded)
|
|
100
|
+
* 5. Queues alerts for state transitions
|
|
101
|
+
* 6. Prunes OFFLINE agents (> offlineThresholdMin)
|
|
102
|
+
*/
|
|
103
|
+
export async function runWatchdogSweep(cfg = DEFAULT_WATCHDOG_CONFIG) {
|
|
104
|
+
const storage = await getStorage();
|
|
105
|
+
const agents = await storage.getAllAgents(PRISM_USER_ID);
|
|
106
|
+
if (agents.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const agent of agents) {
|
|
110
|
+
const heartbeatMs = agent.last_heartbeat
|
|
111
|
+
? new Date(agent.last_heartbeat).getTime()
|
|
112
|
+
: 0;
|
|
113
|
+
// Guard against NaN from malformed timestamps
|
|
114
|
+
if (isNaN(heartbeatMs) || heartbeatMs === 0)
|
|
115
|
+
continue;
|
|
116
|
+
const minutesSinceHeartbeat = (now - heartbeatMs) / 60_000;
|
|
117
|
+
const currentStatus = agent.status;
|
|
118
|
+
// ── State Transition: Heartbeat-based ──────────────────
|
|
119
|
+
let newStatus = null;
|
|
120
|
+
if (minutesSinceHeartbeat >= cfg.offlineThresholdMin) {
|
|
121
|
+
// OFFLINE → prune the agent
|
|
122
|
+
try {
|
|
123
|
+
await storage.deregisterAgent(agent.project, agent.user_id, agent.role);
|
|
124
|
+
queueAlert(agent, "OFFLINE", `No heartbeat for ${Math.floor(minutesSinceHeartbeat)}m — auto-pruned from registry.`);
|
|
125
|
+
console.error(`[Watchdog] ⚫ Agent "${agent.role}" on "${agent.project}" pruned (${Math.floor(minutesSinceHeartbeat)}m offline)`);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error(`[Watchdog] Prune failed for ${agent.project}/${agent.role}: ${err}`);
|
|
129
|
+
}
|
|
130
|
+
continue; // Agent removed, no further processing
|
|
131
|
+
}
|
|
132
|
+
if (minutesSinceHeartbeat >= cfg.frozenThresholdMin) {
|
|
133
|
+
if (currentStatus !== "frozen") {
|
|
134
|
+
newStatus = "frozen";
|
|
135
|
+
queueAlert(agent, "FROZEN", `No heartbeat for ${Math.floor(minutesSinceHeartbeat)}m — agent appears unresponsive.`);
|
|
136
|
+
console.error(`[Watchdog] 🔴 Agent "${agent.role}" on "${agent.project}" is FROZEN (${Math.floor(minutesSinceHeartbeat)}m without heartbeat)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (minutesSinceHeartbeat >= cfg.staleThresholdMin) {
|
|
140
|
+
if (currentStatus !== "stale" && currentStatus !== "frozen") {
|
|
141
|
+
newStatus = "stale";
|
|
142
|
+
queueAlert(agent, "STALE", `No heartbeat for ${Math.floor(minutesSinceHeartbeat)}m — may be experiencing issues.`);
|
|
143
|
+
console.error(`[Watchdog] 🟡 Agent "${agent.role}" on "${agent.project}" is STALE (${Math.floor(minutesSinceHeartbeat)}m without heartbeat)`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ── State Transition: OVERDUE detection ────────────────
|
|
147
|
+
if (!newStatus && // Don't override heartbeat-based transitions
|
|
148
|
+
currentStatus === "active" &&
|
|
149
|
+
agent.task_start_time &&
|
|
150
|
+
agent.expected_duration_minutes &&
|
|
151
|
+
agent.expected_duration_minutes > 0) {
|
|
152
|
+
const taskStartMs = new Date(agent.task_start_time).getTime();
|
|
153
|
+
if (!isNaN(taskStartMs)) {
|
|
154
|
+
const taskElapsedMin = (now - taskStartMs) / 60_000;
|
|
155
|
+
if (taskElapsedMin > agent.expected_duration_minutes) {
|
|
156
|
+
newStatus = "overdue";
|
|
157
|
+
queueAlert(agent, "OVERDUE", `Task "${truncate(agent.current_task || 'unknown', 50)}" running for ` +
|
|
158
|
+
`${Math.floor(taskElapsedMin)}m (expected ${agent.expected_duration_minutes}m).`);
|
|
159
|
+
console.error(`[Watchdog] ⏰ Agent "${agent.role}" on "${agent.project}" is OVERDUE ` +
|
|
160
|
+
`(${Math.floor(taskElapsedMin)}m vs ${agent.expected_duration_minutes}m expected)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ── State Transition: LOOPING confirmation ─────────────
|
|
165
|
+
// Loop detection is primarily done in heartbeatAgent().
|
|
166
|
+
// The watchdog just confirms and queues alerts for it.
|
|
167
|
+
if (!newStatus &&
|
|
168
|
+
agent.loop_count !== undefined &&
|
|
169
|
+
agent.loop_count >= cfg.loopThreshold &&
|
|
170
|
+
currentStatus !== "looping") {
|
|
171
|
+
newStatus = "looping";
|
|
172
|
+
queueAlert(agent, "LOOPING", `Same task repeated ${agent.loop_count} times — possible infinite loop.`);
|
|
173
|
+
console.error(`[Watchdog] 🔄 Agent "${agent.role}" on "${agent.project}" detected LOOPING ` +
|
|
174
|
+
`(task repeated ${agent.loop_count}x)`);
|
|
175
|
+
}
|
|
176
|
+
// ── Apply status update ────────────────────────────────
|
|
177
|
+
if (newStatus && newStatus !== currentStatus) {
|
|
178
|
+
try {
|
|
179
|
+
await storage.updateAgentStatus(agent.project, agent.user_id, agent.role, newStatus);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.error(`[Watchdog] Status update failed for ${agent.project}/${agent.role}: ${err}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
188
|
+
function queueAlert(agent, status, message) {
|
|
189
|
+
const key = `${agent.project}:${agent.role}:${status}`;
|
|
190
|
+
// Only queue if not already pending (deduplication)
|
|
191
|
+
if (!pendingAlerts.has(key)) {
|
|
192
|
+
pendingAlerts.set(key, {
|
|
193
|
+
project: agent.project,
|
|
194
|
+
role: agent.role,
|
|
195
|
+
agentName: agent.agent_name ?? null,
|
|
196
|
+
status,
|
|
197
|
+
message,
|
|
198
|
+
detectedAt: new Date().toISOString(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function truncate(str, maxLen) {
|
|
203
|
+
if (str.length <= maxLen)
|
|
204
|
+
return str;
|
|
205
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
206
|
+
}
|
package/dist/lifecycle.js
CHANGED
|
@@ -24,6 +24,36 @@ const PID_FILE = path.join(PRISM_DIR, `server-${INSTANCE_NAME}.pid`);
|
|
|
24
24
|
function log(msg) {
|
|
25
25
|
console.error(`[Prism Lifecycle] ${msg}`);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Global registry for tracking critical background tasks (like embeddings or SDM writes).
|
|
29
|
+
* Ensures they complete gracefully during server shutdown.
|
|
30
|
+
*/
|
|
31
|
+
export const BackgroundTaskRegistry = {
|
|
32
|
+
tasks: new Set(),
|
|
33
|
+
register(task) {
|
|
34
|
+
this.tasks.add(task);
|
|
35
|
+
task.finally(() => this.tasks.delete(task)).catch(() => { });
|
|
36
|
+
return task;
|
|
37
|
+
},
|
|
38
|
+
async awaitAll(timeoutMs = 5000) {
|
|
39
|
+
if (this.tasks.size === 0)
|
|
40
|
+
return;
|
|
41
|
+
log(`Waiting for ${this.tasks.size} background tasks to complete...`);
|
|
42
|
+
const timeout = new Promise((_, reject) => {
|
|
43
|
+
setTimeout(() => reject(new Error("Timeout waiting for tasks")), timeoutMs);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
await Promise.race([
|
|
47
|
+
Promise.allSettled(Array.from(this.tasks)),
|
|
48
|
+
timeout
|
|
49
|
+
]);
|
|
50
|
+
log("Background tasks completed.");
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
log(`Background tasks shutdown warning: ${e.message || e}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
27
57
|
/**
|
|
28
58
|
* Checks if a process is an orphan (adopted by init/launchd, PPID=1).
|
|
29
59
|
* Returns false on Windows (PID logic is different there).
|
|
@@ -127,15 +157,40 @@ export function registerShutdownHandlers() {
|
|
|
127
157
|
shuttingDown = true;
|
|
128
158
|
log(`Shutting down gracefully (${reason})...`);
|
|
129
159
|
try {
|
|
130
|
-
// 0.
|
|
160
|
+
// 0. Await pending background tasks FIRST (max 5s timeout)
|
|
161
|
+
await BackgroundTaskRegistry.awaitAll(5000);
|
|
162
|
+
// 0.5. Flush OTel span buffer FIRST — before any DBs are closed.
|
|
131
163
|
// BatchSpanProcessor holds spans in memory (up to 5s). If we close
|
|
132
164
|
// DBs first, spans that reference DB operations lose their context.
|
|
133
165
|
// shutdownTelemetry() is a no-op when otel_enabled=false.
|
|
134
166
|
await shutdownTelemetry();
|
|
135
|
-
// 1. Close system settings DB
|
|
136
|
-
closeConfigStorage();
|
|
137
|
-
// 2. Close main ledger DB
|
|
138
167
|
const storage = await getStorage();
|
|
168
|
+
// 1. Flush pending SDM matrices to disk
|
|
169
|
+
try {
|
|
170
|
+
const { getAllActiveSdmProjects, getSdmEngine } = await import("./sdm/sdmEngine.js");
|
|
171
|
+
const sdmProjects = getAllActiveSdmProjects();
|
|
172
|
+
if (sdmProjects.length > 0) {
|
|
173
|
+
log(`Flushing SDM state for ${sdmProjects.length} active projects...`);
|
|
174
|
+
for (const project of sdmProjects) {
|
|
175
|
+
try {
|
|
176
|
+
const sdm = getSdmEngine(project);
|
|
177
|
+
// Ensure we aren't saving an empty state unnecessarily if possible,
|
|
178
|
+
// but UPSERT handles it cleanly regardless.
|
|
179
|
+
const state = sdm.exportState();
|
|
180
|
+
await storage.saveSdmState(project, state);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
log(`Failed to flush SDM state for "${project}": ${err}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
log(`Failed to load SDM engine during shutdown: ${err}`);
|
|
190
|
+
}
|
|
191
|
+
// 2. Close system settings DB
|
|
192
|
+
closeConfigStorage();
|
|
193
|
+
// 3. Close main ledger DB
|
|
139
194
|
if (storage && typeof storage.close === "function") {
|
|
140
195
|
await storage.close();
|
|
141
196
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import { Readability } from '@mozilla/readability';
|
|
4
|
+
import TurndownService from 'turndown';
|
|
5
|
+
/**
|
|
6
|
+
* Searches Yahoo Web Search and parses the HTML results using Cheerio.
|
|
7
|
+
* Yahoo provides a reliable HTML fallback that does not block basic automated browser requests.
|
|
8
|
+
*/
|
|
9
|
+
export async function searchYahooFree(query, limit = 5) {
|
|
10
|
+
const searchUrl = `https://search.yahoo.com/search?p=${encodeURIComponent(query)}`;
|
|
11
|
+
const response = await fetch(searchUrl, {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
headers: {
|
|
14
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`Yahoo Search failed with status: ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
const html = await response.text();
|
|
21
|
+
const $ = cheerio.load(html);
|
|
22
|
+
const results = [];
|
|
23
|
+
$('.algo').each((_, elem) => {
|
|
24
|
+
if (results.length >= limit)
|
|
25
|
+
return false;
|
|
26
|
+
const rawUrl = $(elem).find('a').attr('href') || '';
|
|
27
|
+
let url = rawUrl;
|
|
28
|
+
// Yahoo wraps outbound links in a redirector. Decode the actual target URL.
|
|
29
|
+
if (rawUrl.includes('/RU=')) {
|
|
30
|
+
const afterRu = rawUrl.split('/RU=')[1];
|
|
31
|
+
if (afterRu) {
|
|
32
|
+
const targetUrl = afterRu.split('/RK=')[0];
|
|
33
|
+
url = decodeURIComponent(targetUrl);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const title = $(elem).find('h3').text().trim();
|
|
37
|
+
const snippet = $(elem).find('.compText').text().trim();
|
|
38
|
+
if (url && title) {
|
|
39
|
+
results.push({ title, url, snippet });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fetches an article's HTML, extracts clean content via Readability,
|
|
46
|
+
* and converts it to Markdown using Turndown.
|
|
47
|
+
*/
|
|
48
|
+
export async function scrapeArticleLocal(url) {
|
|
49
|
+
const response = await fetch(url, {
|
|
50
|
+
headers: {
|
|
51
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`Failed to fetch article HTML: ${response.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
const html = await response.text();
|
|
58
|
+
// Create a virtual DOM for Readability to traverse
|
|
59
|
+
const doc = new JSDOM(html, { url });
|
|
60
|
+
// Extract the article content like Firefox Reader View
|
|
61
|
+
const reader = new Readability(doc.window.document);
|
|
62
|
+
const article = reader.parse();
|
|
63
|
+
if (!article) {
|
|
64
|
+
throw new Error("Readability could not parse the article content.");
|
|
65
|
+
}
|
|
66
|
+
// Convert the cleaned HTML to Markdown
|
|
67
|
+
const turndownService = new TurndownService({
|
|
68
|
+
headingStyle: 'atx',
|
|
69
|
+
codeBlockStyle: 'fenced'
|
|
70
|
+
});
|
|
71
|
+
const markdown = turndownService.turndown(article.content || '');
|
|
72
|
+
return {
|
|
73
|
+
title: article.title || 'Unknown Title',
|
|
74
|
+
content: markdown,
|
|
75
|
+
excerpt: article.excerpt || undefined,
|
|
76
|
+
byline: article.byline || undefined
|
|
77
|
+
};
|
|
78
|
+
}
|