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.
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Background Purge Scheduler (v5.4) — Unified Maintenance Automation
3
+ *
4
+ * Automates all storage maintenance tasks that were previously manual-only:
5
+ * 1. TTL Sweep — expireByTTL() for all projects with configured TTL
6
+ * 2. Importance Decay — decayImportance() across all projects
7
+ * 3. Compaction — auto-compact projects exceeding entry threshold
8
+ * 4. Deep Purge — purge float32 embeddings for old compressed entries
9
+ *
10
+ * Architecture:
11
+ * - Single `setInterval` loop (default: 12 hours)
12
+ * - Independent from Hivemind Watchdog (60s loop) — different cadence, different concerns
13
+ * - Non-blocking: all errors caught and logged, sweep never crashes the server
14
+ * - Configurable via env vars: PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS
15
+ *
16
+ * Each sweep runs tasks sequentially (not parallel) to avoid overloading
17
+ * storage backends during maintenance windows.
18
+ */
19
+ import { getStorage } from "./storage/index.js";
20
+ import { PRISM_USER_ID, PRISM_SCHOLAR_ENABLED, PRISM_SCHOLAR_INTERVAL_MS } from "./config.js";
21
+ import { debugLog } from "./utils/logger.js";
22
+ import { runWebScholar } from "./scholar/webScholar.js";
23
+ import { getAllActiveSdmProjects, getSdmEngine } from "./sdm/sdmEngine.js";
24
+ export const DEFAULT_SCHEDULER_CONFIG = {
25
+ intervalMs: 43_200_000, // 12 hours
26
+ enableTTLSweep: true,
27
+ enableDecay: true,
28
+ enableCompaction: true,
29
+ enableDeepPurge: true,
30
+ enableSdmFlush: true,
31
+ purgeOlderThanDays: 30,
32
+ compactionThreshold: 50,
33
+ compactionKeepRecent: 10,
34
+ decayDays: 30,
35
+ };
36
+ // ─── Scheduler State ─────────────────────────────────────────
37
+ let schedulerInterval = null;
38
+ /** Tracks the last completed sweep for dashboard status */
39
+ let lastSweepResult = null;
40
+ /** When the scheduler was started */
41
+ let schedulerStartedAt = null;
42
+ // ─── Public API ──────────────────────────────────────────────
43
+ /**
44
+ * Start the background scheduler.
45
+ * Returns a cleanup function that stops the interval.
46
+ *
47
+ * @param config - Override defaults for testing or production tuning
48
+ */
49
+ export function startScheduler(config) {
50
+ const cfg = { ...DEFAULT_SCHEDULER_CONFIG, ...config };
51
+ if (schedulerInterval) {
52
+ clearTimeout(schedulerInterval);
53
+ }
54
+ schedulerStartedAt = new Date().toISOString();
55
+ const runLoop = () => {
56
+ runSchedulerSweep(cfg)
57
+ .catch(err => {
58
+ console.error(`[Scheduler] Sweep error (non-fatal): ${err}`);
59
+ })
60
+ .finally(() => {
61
+ schedulerInterval = setTimeout(runLoop, cfg.intervalMs);
62
+ });
63
+ };
64
+ // Run an immediate first sweep (after a short delay to let storage fully warm up)
65
+ schedulerInterval = setTimeout(runLoop, 5_000);
66
+ const enabledTasks = [
67
+ cfg.enableTTLSweep && "TTL",
68
+ cfg.enableDecay && "Decay",
69
+ cfg.enableCompaction && "Compaction",
70
+ cfg.enableDeepPurge && "DeepPurge",
71
+ cfg.enableSdmFlush && "SdmFlush",
72
+ ].filter(Boolean).join(", ");
73
+ console.error(`[Scheduler] ⏰ Started (interval=${formatDuration(cfg.intervalMs)}, tasks=[${enabledTasks}])`);
74
+ return () => {
75
+ if (schedulerInterval) {
76
+ clearTimeout(schedulerInterval);
77
+ schedulerInterval = null;
78
+ console.error("[Scheduler] Stopped");
79
+ }
80
+ };
81
+ }
82
+ /**
83
+ * Get scheduler status for the dashboard.
84
+ */
85
+ export function getSchedulerStatus() {
86
+ return {
87
+ running: schedulerInterval !== null,
88
+ startedAt: schedulerStartedAt,
89
+ intervalMs: DEFAULT_SCHEDULER_CONFIG.intervalMs,
90
+ lastSweep: lastSweepResult,
91
+ scholarRunning: scholarInterval !== null,
92
+ scholarIntervalMs: PRISM_SCHOLAR_INTERVAL_MS,
93
+ };
94
+ }
95
+ // ─── Scholar State ───────────────────────────────────────────
96
+ let scholarInterval = null;
97
+ export function startScholarScheduler() {
98
+ if (scholarInterval) {
99
+ clearTimeout(scholarInterval);
100
+ }
101
+ if (!PRISM_SCHOLAR_ENABLED || PRISM_SCHOLAR_INTERVAL_MS <= 0) {
102
+ debugLog("[WebScholar] 🕒 Scheduler disabled (PRISM_SCHOLAR_ENABLED=false or PRISM_SCHOLAR_INTERVAL_MS=0)");
103
+ return () => { };
104
+ }
105
+ const runLoop = () => {
106
+ runWebScholar()
107
+ .catch(err => {
108
+ console.error(`[WebScholar] Sweep error: ${err}`);
109
+ })
110
+ .finally(() => {
111
+ scholarInterval = setTimeout(runLoop, PRISM_SCHOLAR_INTERVAL_MS);
112
+ });
113
+ };
114
+ // Initial trigger after 30s to avoid thundering herd on boot
115
+ scholarInterval = setTimeout(runLoop, 30_000);
116
+ console.error(`[WebScholar] ⏰ Started (interval=${formatDuration(PRISM_SCHOLAR_INTERVAL_MS)})`);
117
+ return () => {
118
+ if (scholarInterval) {
119
+ clearTimeout(scholarInterval);
120
+ scholarInterval = null;
121
+ console.error("[WebScholar] Stopped");
122
+ }
123
+ };
124
+ }
125
+ // ─── Core Sweep Logic ────────────────────────────────────────
126
+ /**
127
+ * Single scheduler sweep — orchestrates all maintenance tasks.
128
+ * Exported for testing.
129
+ *
130
+ * Execution order:
131
+ * 1. TTL Sweep — lightweight SQL UPDATEs (fast)
132
+ * 2. Importance Decay — lightweight SQL UPDATEs (fast)
133
+ * 3. Compaction — LLM-powered summarization (slow, expensive)
134
+ * 4. Deep Purge — SQL UPDATEs to NULL embeddings (moderate)
135
+ */
136
+ export async function runSchedulerSweep(cfg = DEFAULT_SCHEDULER_CONFIG) {
137
+ const sweepStart = Date.now();
138
+ const startedAt = new Date().toISOString();
139
+ const result = {
140
+ startedAt,
141
+ completedAt: "",
142
+ durationMs: 0,
143
+ tasks: {
144
+ ttlSweep: { ran: false, projectsSwept: 0, totalExpired: 0 },
145
+ importanceDecay: { ran: false, projectsDecayed: 0 },
146
+ compaction: { ran: false, projectsCompacted: 0 },
147
+ deepPurge: { ran: false, purged: 0, reclaimedBytes: 0 },
148
+ sdmFlush: { ran: false, projectsFlushed: 0 },
149
+ },
150
+ };
151
+ debugLog("[Scheduler] 🔄 Sweep starting...");
152
+ const storage = await getStorage();
153
+ // ── Task 1: TTL Sweep ──────────────────────────────────────
154
+ if (cfg.enableTTLSweep) {
155
+ try {
156
+ result.tasks.ttlSweep.ran = true;
157
+ const projects = await storage.listProjects();
158
+ const settings = await storage.getAllSettings();
159
+ for (const project of projects) {
160
+ const ttlKey = `ttl:${project}`;
161
+ const ttlValue = settings[ttlKey];
162
+ if (!ttlValue)
163
+ continue;
164
+ const ttlDays = parseInt(ttlValue, 10);
165
+ if (isNaN(ttlDays) || ttlDays <= 0)
166
+ continue;
167
+ try {
168
+ const { expired } = await storage.expireByTTL(project, ttlDays, PRISM_USER_ID);
169
+ result.tasks.ttlSweep.projectsSwept++;
170
+ result.tasks.ttlSweep.totalExpired += expired;
171
+ if (expired > 0) {
172
+ debugLog(`[Scheduler] TTL: expired ${expired} entries for "${project}" (>${ttlDays}d)`);
173
+ }
174
+ }
175
+ catch (err) {
176
+ debugLog(`[Scheduler] TTL sweep failed for "${project}": ${err}`);
177
+ }
178
+ }
179
+ }
180
+ catch (err) {
181
+ result.tasks.ttlSweep.error = err instanceof Error ? err.message : String(err);
182
+ console.error(`[Scheduler] TTL sweep error: ${err}`);
183
+ }
184
+ }
185
+ // ── Task 2: Importance Decay ───────────────────────────────
186
+ if (cfg.enableDecay) {
187
+ try {
188
+ result.tasks.importanceDecay.ran = true;
189
+ const projects = await storage.listProjects();
190
+ for (const project of projects) {
191
+ try {
192
+ await storage.decayImportance(project, PRISM_USER_ID, cfg.decayDays);
193
+ result.tasks.importanceDecay.projectsDecayed++;
194
+ }
195
+ catch (err) {
196
+ debugLog(`[Scheduler] Decay failed for "${project}": ${err}`);
197
+ }
198
+ }
199
+ }
200
+ catch (err) {
201
+ result.tasks.importanceDecay.error = err instanceof Error ? err.message : String(err);
202
+ console.error(`[Scheduler] Importance decay error: ${err}`);
203
+ }
204
+ }
205
+ // ── Task 3: Compaction ─────────────────────────────────────
206
+ // NOTE: Compaction uses LLM summarization which is expensive.
207
+ // We only trigger the candidate detection here — actual compaction
208
+ // is deferred to avoid blocking the sweep with long LLM calls.
209
+ // Instead, we log which projects need compaction for dashboard visibility.
210
+ if (cfg.enableCompaction) {
211
+ try {
212
+ result.tasks.compaction.ran = true;
213
+ const candidates = await storage.getCompactionCandidates(cfg.compactionThreshold, cfg.compactionKeepRecent, PRISM_USER_ID);
214
+ if (candidates.length > 0) {
215
+ // Import compaction handler dynamically to avoid circular deps
216
+ const { compactLedgerHandler } = await import("./tools/compactionHandler.js");
217
+ for (const candidate of candidates) {
218
+ try {
219
+ debugLog(`[Scheduler] Compacting "${candidate.project}": ` +
220
+ `${candidate.total_entries} entries (${candidate.to_compact} to compact)`);
221
+ await compactLedgerHandler({
222
+ project: candidate.project,
223
+ threshold: cfg.compactionThreshold,
224
+ keep_recent: cfg.compactionKeepRecent,
225
+ dry_run: false,
226
+ });
227
+ result.tasks.compaction.projectsCompacted++;
228
+ }
229
+ catch (err) {
230
+ debugLog(`[Scheduler] Compaction failed for "${candidate.project}": ${err}`);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ catch (err) {
236
+ result.tasks.compaction.error = err instanceof Error ? err.message : String(err);
237
+ console.error(`[Scheduler] Compaction error: ${err}`);
238
+ }
239
+ }
240
+ // ── Task 4: Deep Purge ─────────────────────────────────────
241
+ if (cfg.enableDeepPurge) {
242
+ try {
243
+ result.tasks.deepPurge.ran = true;
244
+ const purgeResult = await storage.purgeHighPrecisionEmbeddings({
245
+ olderThanDays: cfg.purgeOlderThanDays,
246
+ dryRun: false,
247
+ userId: PRISM_USER_ID,
248
+ });
249
+ result.tasks.deepPurge.purged = purgeResult.purged;
250
+ result.tasks.deepPurge.reclaimedBytes = purgeResult.reclaimedBytes;
251
+ if (purgeResult.purged > 0) {
252
+ debugLog(`[Scheduler] Deep purge: freed ${formatBytes(purgeResult.reclaimedBytes)} ` +
253
+ `(${purgeResult.purged} entries purged)`);
254
+ }
255
+ }
256
+ catch (err) {
257
+ result.tasks.deepPurge.error = err instanceof Error ? err.message : String(err);
258
+ console.error(`[Scheduler] Deep purge error: ${err}`);
259
+ }
260
+ }
261
+ // ── Task 5: SDM Flush ──────────────────────────────────────
262
+ if (cfg.enableSdmFlush) {
263
+ try {
264
+ result.tasks.sdmFlush.ran = true;
265
+ const activeProjects = getAllActiveSdmProjects();
266
+ for (const project of activeProjects) {
267
+ try {
268
+ const sdm = getSdmEngine(project);
269
+ const state = sdm.exportState();
270
+ await storage.saveSdmState(project, state);
271
+ result.tasks.sdmFlush.projectsFlushed++;
272
+ }
273
+ catch (err) {
274
+ debugLog(`[Scheduler] SDM flush failed for "${project}": ${err}`);
275
+ }
276
+ }
277
+ if (result.tasks.sdmFlush.projectsFlushed > 0) {
278
+ debugLog(`[Scheduler] SDM flush: saved matrices for ${result.tasks.sdmFlush.projectsFlushed} projects`);
279
+ }
280
+ }
281
+ catch (err) {
282
+ result.tasks.sdmFlush.error = err instanceof Error ? err.message : String(err);
283
+ console.error(`[Scheduler] SDM flush error: ${err}`);
284
+ }
285
+ }
286
+ // ── Finalize ───────────────────────────────────────────────
287
+ result.completedAt = new Date().toISOString();
288
+ result.durationMs = Date.now() - sweepStart;
289
+ lastSweepResult = result;
290
+ // Build summary line
291
+ const parts = [];
292
+ if (result.tasks.ttlSweep.ran && result.tasks.ttlSweep.totalExpired > 0) {
293
+ parts.push(`TTL:${result.tasks.ttlSweep.totalExpired} expired`);
294
+ }
295
+ if (result.tasks.importanceDecay.ran) {
296
+ parts.push(`Decay:${result.tasks.importanceDecay.projectsDecayed} projects`);
297
+ }
298
+ if (result.tasks.compaction.ran && result.tasks.compaction.projectsCompacted > 0) {
299
+ parts.push(`Compact:${result.tasks.compaction.projectsCompacted} projects`);
300
+ }
301
+ if (result.tasks.deepPurge.ran && result.tasks.deepPurge.purged > 0) {
302
+ parts.push(`Purge:${result.tasks.deepPurge.purged} entries (${formatBytes(result.tasks.deepPurge.reclaimedBytes)})`);
303
+ }
304
+ if (result.tasks.sdmFlush.ran && result.tasks.sdmFlush.projectsFlushed > 0) {
305
+ parts.push(`SDM:${result.tasks.sdmFlush.projectsFlushed} projects`);
306
+ }
307
+ const summaryLine = parts.length > 0
308
+ ? parts.join(" | ")
309
+ : "no maintenance actions needed";
310
+ debugLog(`[Scheduler] ✅ Sweep completed in ${result.durationMs}ms — ${summaryLine}`);
311
+ return result;
312
+ }
313
+ // ─── Helpers ─────────────────────────────────────────────────
314
+ function formatDuration(ms) {
315
+ if (ms < 60_000)
316
+ return `${ms}ms`;
317
+ if (ms < 3_600_000)
318
+ return `${Math.round(ms / 60_000)}m`;
319
+ return `${Math.round(ms / 3_600_000)}h`;
320
+ }
321
+ function formatBytes(bytes) {
322
+ if (bytes < 1024)
323
+ return `${bytes}B`;
324
+ if (bytes < 1_048_576)
325
+ return `${(bytes / 1024).toFixed(1)}KB`;
326
+ return `${(bytes / 1_048_576).toFixed(1)}MB`;
327
+ }
package/dist/config.js CHANGED
@@ -128,3 +128,32 @@ if (PRISM_AUTO_CAPTURE) {
128
128
  console.error(`[AutoCapture] Enabled for ports: ${PRISM_CAPTURE_PORTS.join(", ")}`);
129
129
  }
130
130
  }
131
+ // ─── v5.3: Hivemind Watchdog Thresholds ──────────────────────
132
+ // All values have sane defaults. Override via env vars only for
133
+ // testing or production tuning. Dashboard UI exposure deferred to v5.4.
134
+ export const WATCHDOG_INTERVAL_MS = parseInt(process.env.PRISM_WATCHDOG_INTERVAL_MS || "60000", 10);
135
+ export const WATCHDOG_STALE_MIN = parseInt(process.env.PRISM_WATCHDOG_STALE_MIN || "5", 10);
136
+ export const WATCHDOG_FROZEN_MIN = parseInt(process.env.PRISM_WATCHDOG_FROZEN_MIN || "15", 10);
137
+ export const WATCHDOG_OFFLINE_MIN = parseInt(process.env.PRISM_WATCHDOG_OFFLINE_MIN || "30", 10);
138
+ export const WATCHDOG_LOOP_THRESHOLD = parseInt(process.env.PRISM_WATCHDOG_LOOP_THRESHOLD || "5", 10);
139
+ // ─── v5.4: Background Purge Scheduler ────────────────────────
140
+ // Automated background maintenance: TTL sweep, importance decay,
141
+ // compaction, and deep storage purge. Runs independently from
142
+ // the Watchdog (different cadence: 12h vs 60s).
143
+ export const PRISM_SCHEDULER_ENABLED = process.env.PRISM_SCHEDULER_ENABLED !== "false"; // Default: true
144
+ export const PRISM_SCHEDULER_INTERVAL_MS = parseInt(process.env.PRISM_SCHEDULER_INTERVAL_MS || "43200000", 10 // 12 hours
145
+ );
146
+ // ─── v5.4: Autonomous Web Scholar ─────────────────────────────
147
+ // Background LLM research pipeline powered by Brave Search + Firecrawl.
148
+ // Defaults are conservative to prevent runaway API costs.
149
+ export const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
150
+ export const PRISM_SCHOLAR_ENABLED = process.env.PRISM_SCHOLAR_ENABLED === "true"; // Opt-in
151
+ if (PRISM_SCHOLAR_ENABLED && !FIRECRAWL_API_KEY) {
152
+ console.error("Warning: FIRECRAWL_API_KEY environment variable is missing. Web Scholar will be unavailable.");
153
+ }
154
+ export const PRISM_SCHOLAR_INTERVAL_MS = parseInt(process.env.PRISM_SCHOLAR_INTERVAL_MS || "0", 10 // Default manual-only
155
+ );
156
+ export const PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN = parseInt(process.env.PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN || "3", 10);
157
+ export const PRISM_SCHOLAR_TOPICS = (process.env.PRISM_SCHOLAR_TOPICS || "ai,agents")
158
+ .split(",")
159
+ .map(t => t.trim());
@@ -864,6 +864,249 @@ return false;}
864
864
  return res.end(JSON.stringify({ error: err.message || "Import failed" }));
865
865
  }
866
866
  }
867
+ // ─── API: Background Scheduler Status (v5.4) ────────────
868
+ if (url.pathname === "/api/scheduler" && req.method === "GET") {
869
+ try {
870
+ const { getSchedulerStatus } = await import("../backgroundScheduler.js");
871
+ const status = getSchedulerStatus();
872
+ res.writeHead(200, { "Content-Type": "application/json" });
873
+ return res.end(JSON.stringify(status));
874
+ }
875
+ catch (err) {
876
+ console.error("[Dashboard] Scheduler status error:", err);
877
+ res.writeHead(200, { "Content-Type": "application/json" });
878
+ return res.end(JSON.stringify({
879
+ running: false, startedAt: null, intervalMs: 0, lastSweep: null,
880
+ }));
881
+ }
882
+ }
883
+ // ─── API: Autonomous Web Scholar Trigger (v5.4) ─────────
884
+ if (url.pathname === "/api/scholar/trigger" && req.method === "POST") {
885
+ try {
886
+ const { runWebScholar } = await import("../scholar/webScholar.js");
887
+ // Fire and forget, don't block the request
888
+ runWebScholar().catch(err => {
889
+ console.error("[Dashboard] Web Scholar async trigger failed:", err);
890
+ });
891
+ res.writeHead(200, { "Content-Type": "application/json" });
892
+ return res.end(JSON.stringify({ ok: true, message: "Autonomous research started in background" }));
893
+ }
894
+ catch (err) {
895
+ console.error("[Dashboard] Web Scholar trigger error:", err);
896
+ res.writeHead(500, { "Content-Type": "application/json" });
897
+ return res.end(JSON.stringify({ error: err.message || "Failed to trigger Web Scholar" }));
898
+ }
899
+ }
900
+ if (url.pathname === "/manifest.json" && req.method === "GET") {
901
+ const manifest = {
902
+ name: "Prism Mind Palace",
903
+ short_name: "Prism",
904
+ description: "Prism MCP Mobile Dashboard",
905
+ start_url: "/",
906
+ display: "standalone",
907
+ background_color: "#0a0e1a",
908
+ theme_color: "#0a0e1a",
909
+ icons: [
910
+ { src: "/icon-192.svg", sizes: "192x192", type: "image/svg+xml", purpose: "any" },
911
+ { src: "/icon-192-maskable.svg", sizes: "192x192", type: "image/svg+xml", purpose: "maskable" },
912
+ { src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "any" },
913
+ { src: "/icon-512-maskable.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" }
914
+ ]
915
+ };
916
+ res.writeHead(200, {
917
+ "Content-Type": "application/json",
918
+ "Cache-Control": "public, max-age=86400"
919
+ });
920
+ return res.end(JSON.stringify(manifest));
921
+ }
922
+ // ─── PWA: Service Worker (v5.4) ───
923
+ if (url.pathname === "/sw.js" && req.method === "GET") {
924
+ const swContent = `
925
+ const CACHE_NAME = 'prism-pwa-v2.1';
926
+ const ASSETS = [
927
+ '/',
928
+ '/manifest.json',
929
+ '/icon-192.svg',
930
+ '/icon-192-maskable.svg',
931
+ '/icon-512.svg',
932
+ '/icon-512-maskable.svg',
933
+ '/apple-touch-icon.png',
934
+ '/offline.html'
935
+ ];
936
+
937
+ self.addEventListener('install', (e) => {
938
+ e.waitUntil(caches.open(CACHE_NAME).then((c) => c.addAll(ASSETS)));
939
+ self.skipWaiting();
940
+ });
941
+
942
+ self.addEventListener('activate', (e) => {
943
+ e.waitUntil(caches.keys().then((keys) => {
944
+ return Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)));
945
+ }));
946
+ self.clients.claim();
947
+ });
948
+
949
+ self.addEventListener('fetch', (e) => {
950
+ if (e.request.method !== 'GET') return;
951
+ // Serve offline page for HTML navigation
952
+ if (e.request.mode === 'navigate') {
953
+ e.respondWith(fetch(e.request).catch(() => caches.match('/offline.html')));
954
+ return;
955
+ }
956
+ // Network-first for API requests, Cache-first for Assets
957
+ if (e.request.url.includes('/api/')) {
958
+ e.respondWith(fetch(e.request).catch(() => new Response(JSON.stringify({ error: "Offline" }), { headers: { "Content-Type": "application/json" }, status: 503 })));
959
+ } else {
960
+ e.respondWith(caches.match(e.request).then((res) => res || fetch(e.request).then((fres) => {
961
+ // Cache dynamically fetched non-API assets
962
+ return caches.open(CACHE_NAME).then(c => { c.put(e.request, fres.clone()); return fres; });
963
+ }).catch(() => null)));
964
+ }
965
+ });
966
+
967
+ self.addEventListener('message', (e) => {
968
+ if (e.data && e.data.action === 'skipWaiting') {
969
+ self.skipWaiting();
970
+ }
971
+ });
972
+ `.trim();
973
+ res.writeHead(200, {
974
+ "Content-Type": "application/javascript",
975
+ "Cache-Control": "no-cache"
976
+ });
977
+ return res.end(swContent);
978
+ }
979
+ // ─── PWA: Offline Fallback HTML (v5.4) ───
980
+ if (url.pathname === "/offline.html" && req.method === "GET") {
981
+ const offlineHtml = `<!DOCTYPE html>
982
+ <html lang="en">
983
+ <head>
984
+ <meta charset="UTF-8">
985
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
986
+ <title>Prism MCP — Offline</title>
987
+ <link rel="manifest" href="/manifest.json">
988
+ <meta name="theme-color" content="#0a0e1a">
989
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
990
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
991
+ <style>
992
+ * { box-sizing: border-box; margin: 0; padding: 0; }
993
+ body {
994
+ background: #0a0e1a;
995
+ color: #f1f5f9;
996
+ font-family: 'Inter', sans-serif;
997
+ min-height: 100vh;
998
+ display: flex;
999
+ flex-direction: column;
1000
+ align-items: center;
1001
+ justify-content: center;
1002
+ text-align: center;
1003
+ padding: 2rem;
1004
+ }
1005
+ .bg {
1006
+ position: fixed;
1007
+ inset: 0;
1008
+ background-image:
1009
+ radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.08) 0%, transparent 50%),
1010
+ radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.06) 0%, transparent 50%);
1011
+ z-index: 0;
1012
+ }
1013
+ .card {
1014
+ position: relative;
1015
+ z-index: 1;
1016
+ background: rgba(17, 24, 39, 0.6);
1017
+ backdrop-filter: blur(16px);
1018
+ border: 1px solid rgba(139, 92, 246, 0.15);
1019
+ border-radius: 16px;
1020
+ padding: 3rem 2rem;
1021
+ max-width: 400px;
1022
+ width: 100%;
1023
+ }
1024
+ .icon {
1025
+ font-size: 3rem;
1026
+ margin-bottom: 1.5rem;
1027
+ opacity: 0.8;
1028
+ filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.5));
1029
+ }
1030
+ h1 {
1031
+ font-size: 1.5rem;
1032
+ font-weight: 600;
1033
+ margin-bottom: 1rem;
1034
+ background: linear-gradient(135deg, #8b5cf6, #3b82f6);
1035
+ -webkit-background-clip: text;
1036
+ -webkit-text-fill-color: transparent;
1037
+ }
1038
+ p {
1039
+ color: #94a3b8;
1040
+ font-size: 0.95rem;
1041
+ line-height: 1.5;
1042
+ margin-bottom: 2rem;
1043
+ }
1044
+ button {
1045
+ background: linear-gradient(135deg, #8b5cf6, #3b82f6);
1046
+ color: white;
1047
+ border: none;
1048
+ padding: 0.75rem 1.5rem;
1049
+ border-radius: 8px;
1050
+ font-size: 0.95rem;
1051
+ font-weight: 600;
1052
+ cursor: pointer;
1053
+ font-family: 'Inter', sans-serif;
1054
+ transition: opacity 0.2s;
1055
+ }
1056
+ button:hover { opacity: 0.9; }
1057
+ </style>
1058
+ </head>
1059
+ <body>
1060
+ <div class="bg"></div>
1061
+ <div class="card">
1062
+ <div class="icon">🔌</div>
1063
+ <h1>You are currently offline.</h1>
1064
+ <p>The Prism Dashboard cannot reach the MCP server. Please check your internet connection to resume.</p>
1065
+ <button onclick="window.location.reload()">Try Again</button>
1066
+ </div>
1067
+ </body>
1068
+ </html>`;
1069
+ res.writeHead(200, {
1070
+ "Content-Type": "text/html",
1071
+ "Cache-Control": "public, max-age=86400"
1072
+ });
1073
+ return res.end(offlineHtml);
1074
+ }
1075
+ // ─── PWA: Dynamic SVG Icons (v5.4) ───
1076
+ if ((url.pathname === "/icon-192.svg" || url.pathname === "/icon-512.svg" || url.pathname === "/icon-192-maskable.svg" || url.pathname === "/icon-512-maskable.svg") && req.method === "GET") {
1077
+ const size = url.pathname.includes("192") ? 192 : 512;
1078
+ const isMaskable = url.pathname.includes("maskable");
1079
+ // For standard "any" icons, we might want rounded corners or a specific size ratio if needed,
1080
+ // but since we separate purposes, we can keep the SVG identical as adaptive scaling is handled by OS
1081
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">
1082
+ <defs>
1083
+ <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
1084
+ <stop offset="0%" stop-color="#8b5cf6" />
1085
+ <stop offset="50%" stop-color="#3b82f6" />
1086
+ <stop offset="100%" stop-color="#06b6d4" />
1087
+ </linearGradient>
1088
+ </defs>
1089
+ <rect width="${size}" height="${size}" rx="${isMaskable ? 0 : Math.floor(size * 0.2)}" fill="#0a0e1a"/>
1090
+ <path d="M${size * 0.5} ${size * 0.25} L${size * 0.75} ${size * 0.75} L${size * 0.25} ${size * 0.75} Z" fill="url(#grad)" opacity="0.9"/>
1091
+ <circle cx="${size * 0.5}" cy="${size * 0.55}" r="${size * 0.15}" fill="#ffffff" opacity="0.1" />
1092
+ </svg>`;
1093
+ res.writeHead(200, {
1094
+ "Content-Type": "image/svg+xml",
1095
+ "Cache-Control": "public, max-age=86400"
1096
+ });
1097
+ return res.end(svg);
1098
+ }
1099
+ // ─── PWA: iOS Apple Touch Icon (v5.5) ───
1100
+ if (url.pathname === "/apple-touch-icon.png" && req.method === "GET") {
1101
+ // iOS Safari does not support SVG for apple-touch-icon; requires PNG.
1102
+ // We serve a robust Base64-encoded 180x180 opaque PNG to prevent the "black box" issue.
1103
+ const pngBase64 = "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";
1104
+ res.writeHead(200, {
1105
+ "Content-Type": "image/png",
1106
+ "Cache-Control": "public, max-age=86400"
1107
+ });
1108
+ return res.end(Buffer.from(pngBase64, "base64"));
1109
+ }
867
1110
  // ─── 404 ───
868
1111
  res.writeHead(404, { "Content-Type": "text/plain" });
869
1112
  res.end("Not found");
@@ -930,6 +1173,9 @@ return false;}
930
1173
  }
931
1174
  console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
932
1175
  // ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
1176
+ // NOTE (v5.4): The Background Scheduler in server.ts now also handles
1177
+ // TTL sweeps. This dashboard sweep is kept as a legacy fallback for
1178
+ // deployments where the scheduler is disabled. Both are idempotent.
933
1179
  async function runTtlSweep() {
934
1180
  try {
935
1181
  const allSettings = await getAllSettings();