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.
@@ -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 (v3.0) ───
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="pulse-dot"></span>' +
2235
+ '<span class="' + dotClass + '" style="background:' + dotColor + '"></span>' +
2168
2236
  '<span class="team-role">' + icon + ' ' + escapeHtml(a.role) + '</span>' +
2169
- '<span class="team-task">' + escapeHtml(a.current_task || 'idle') + '</span>' +
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. Flush OTel span buffer FIRST before any DBs are closed.
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
+ }