prism-mcp-server 4.6.1 → 5.2.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.
@@ -17,6 +17,9 @@
17
17
  * ═══════════════════════════════════════════════════════════════════
18
18
  */
19
19
  import * as http from "http";
20
+ import * as path from "path";
21
+ import * as os from "os";
22
+ import * as fs from "fs";
20
23
  import { exec } from "child_process";
21
24
  import { getStorage } from "../storage/index.js";
22
25
  import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
@@ -75,10 +78,14 @@ async function killPortHolder(port) {
75
78
  });
76
79
  }
77
80
  export async function startDashboardServer() {
78
- // Fire-and-forget port cleanup don't block server start.
79
- // Previously awaiting this added 300ms+ delay from lsof + setTimeout,
80
- // starving the MCP stdio transport during the init handshake.
81
- killPortHolder(PORT).catch(() => { });
81
+ // Await port cleanup before binding. This adds ~300ms from lsof + setTimeout,
82
+ // but is safe because startDashboardServer() is already deferred to
83
+ // setTimeout(0) in server.ts — the MCP stdio handshake is long finished.
84
+ // The old fire-and-forget approach caused a deadly race condition:
85
+ // 1. listen() fired BEFORE killPortHolder cleared the port → EADDRINUSE
86
+ // 2. killPortHolder then killed the OTHER instance's entire process
87
+ // 3. Result: no instance ever held port 3000
88
+ await killPortHolder(PORT).catch(() => { });
82
89
  // Lazy storage accessor — returns null if storage isn't ready yet.
83
90
  // API routes gracefully degrade with 503 instead of blocking startup.
84
91
  let _storage = null;
@@ -93,15 +100,154 @@ export async function startDashboardServer() {
93
100
  return null;
94
101
  }
95
102
  };
103
+ /**
104
+ * v5.1: Optional HTTP Basic Auth for remote dashboard access.
105
+ *
106
+ * HOW IT WORKS:
107
+ * 1. If PRISM_DASHBOARD_USER and PRISM_DASHBOARD_PASS are NOT set → auth is disabled (backward compatible)
108
+ * 2. If set → every request must provide Basic Auth credentials OR a valid session cookie
109
+ * 3. On successful auth → a session cookie (24h) is set so users don't re-authenticate on every request
110
+ * 4. On failure → a styled login page is shown (not a raw 401 popup)
111
+ *
112
+ * SECURITY NOTES:
113
+ * - This is HTTP Basic Auth — suitable for LAN/VPN access, NOT public internet without HTTPS
114
+ * - Session tokens are random 64-char hex strings stored in-memory (cleared on server restart)
115
+ * - Timing-safe comparison prevents credential timing attacks
116
+ */
117
+ const AUTH_USER = process.env.PRISM_DASHBOARD_USER || "";
118
+ const AUTH_PASS = process.env.PRISM_DASHBOARD_PASS || "";
119
+ const AUTH_ENABLED = AUTH_USER.length > 0 && AUTH_PASS.length > 0;
120
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
121
+ const activeSessions = new Map(); // token → expiry timestamp
122
+ /** Generate a random session token */
123
+ function generateToken() {
124
+ const chars = "abcdef0123456789";
125
+ let token = "";
126
+ for (let i = 0; i < 64; i++) {
127
+ token += chars[Math.floor(Math.random() * chars.length)];
128
+ }
129
+ return token;
130
+ }
131
+ /** Timing-safe string comparison to prevent timing attacks */
132
+ function safeCompare(a, b) {
133
+ if (a.length !== b.length)
134
+ return false;
135
+ let result = 0;
136
+ for (let i = 0; i < a.length; i++) {
137
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
138
+ }
139
+ return result === 0;
140
+ }
141
+ /** Check if request is authenticated (returns true if auth is disabled) */
142
+ function isAuthenticated(req) {
143
+ if (!AUTH_ENABLED)
144
+ return true;
145
+ // Check session cookie first
146
+ const cookies = req.headers.cookie || "";
147
+ const match = cookies.match(/prism_session=([a-f0-9]{64})/);
148
+ if (match) {
149
+ const token = match[1];
150
+ const expiry = activeSessions.get(token);
151
+ if (expiry && expiry > Date.now())
152
+ return true;
153
+ // Expired — clean up
154
+ if (expiry)
155
+ activeSessions.delete(token);
156
+ }
157
+ // Check Basic Auth header
158
+ const authHeader = req.headers.authorization || "";
159
+ if (authHeader.startsWith("Basic ")) {
160
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
161
+ const [user, pass] = decoded.split(":");
162
+ return safeCompare(user || "", AUTH_USER) && safeCompare(pass || "", AUTH_PASS);
163
+ }
164
+ return false;
165
+ }
166
+ /** Render a styled login page matching the Mind Palace theme */
167
+ function renderLoginPage() {
168
+ return `<!DOCTYPE html>
169
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
170
+ <title>Prism MCP — Login</title>
171
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
172
+ <style>
173
+ *{box-sizing:border-box;margin:0;padding:0}
174
+ body{background:#0a0e1a;color:#f1f5f9;font-family:'Inter',sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
175
+ .bg{position:fixed;inset:0;background-image:radial-gradient(circle at 20% 30%,rgba(139,92,246,0.08) 0%,transparent 50%),radial-gradient(circle at 80% 70%,rgba(59,130,246,0.06) 0%,transparent 50%)}
176
+ .login-card{position:relative;z-index:1;background:rgba(17,24,39,0.6);backdrop-filter:blur(16px);border:1px solid rgba(139,92,246,0.15);border-radius:16px;padding:2.5rem;width:380px;max-width:90vw;text-align:center}
177
+ .logo{font-size:1.75rem;font-weight:700;background:linear-gradient(135deg,#8b5cf6,#3b82f6,#06b6d4);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:0.5rem}
178
+ .subtitle{color:#64748b;font-size:0.85rem;margin-bottom:2rem}
179
+ .field{margin-bottom:1rem}
180
+ .field input{width:100%;padding:0.7rem 1rem;background:#111827;border:1px solid rgba(139,92,246,0.15);border-radius:10px;color:#f1f5f9;font-size:0.9rem;font-family:'Inter',sans-serif;outline:none;transition:border-color 0.2s}
181
+ .field input:focus{border-color:rgba(139,92,246,0.5)}
182
+ .field input::placeholder{color:#475569}
183
+ .login-btn{width:100%;padding:0.75rem;background:linear-gradient(135deg,#8b5cf6,#3b82f6);color:white;border:none;border-radius:10px;font-size:0.95rem;font-weight:600;cursor:pointer;transition:opacity 0.2s;margin-top:0.5rem}
184
+ .login-btn:hover{opacity:0.9}
185
+ .error{color:#f43f5e;font-size:0.8rem;margin-top:1rem;display:none}
186
+ .lock{font-size:2rem;margin-bottom:1rem}
187
+ </style></head><body>
188
+ <div class="bg"></div>
189
+ <div class="login-card">
190
+ <div class="lock">🔒</div>
191
+ <div class="logo">🧠 Prism Mind Palace</div>
192
+ <div class="subtitle">Authentication required for remote access</div>
193
+ <form id="loginForm" onsubmit="return handleLogin(event)">
194
+ <div class="field"><input type="text" id="user" placeholder="Username" autocomplete="username" required></div>
195
+ <div class="field"><input type="password" id="pass" placeholder="Password" autocomplete="current-password" required></div>
196
+ <button type="submit" class="login-btn">Sign In</button>
197
+ </form>
198
+ <div class="error" id="error">Invalid credentials</div>
199
+ </div>
200
+ <script>
201
+ async function handleLogin(e){e.preventDefault();
202
+ var u=document.getElementById('user').value,p=document.getElementById('pass').value;
203
+ var r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:u,pass:p})});
204
+ if(r.ok){window.location.reload();}else{document.getElementById('error').style.display='block';}
205
+ return false;}
206
+ </script></body></html>`;
207
+ }
208
+ if (AUTH_ENABLED) {
209
+ console.error(`[Dashboard] 🔒 Auth enabled for user "${AUTH_USER}"`);
210
+ }
96
211
  const httpServer = http.createServer(async (req, res) => {
97
212
  // CORS headers for local dev
98
213
  res.setHeader("Access-Control-Allow-Origin", "*");
99
214
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
100
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
215
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
101
216
  if (req.method === "OPTIONS") {
102
217
  res.writeHead(204);
103
218
  return res.end();
104
219
  }
220
+ // ─── v5.1: Auth login endpoint (always accessible) ───
221
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
222
+ if (AUTH_ENABLED && reqUrl.pathname === "/api/auth/login" && req.method === "POST") {
223
+ const body = await readBody(req);
224
+ try {
225
+ const { user, pass } = JSON.parse(body);
226
+ if (safeCompare(user || "", AUTH_USER) && safeCompare(pass || "", AUTH_PASS)) {
227
+ const token = generateToken();
228
+ activeSessions.set(token, Date.now() + SESSION_TTL_MS);
229
+ res.writeHead(200, {
230
+ "Content-Type": "application/json",
231
+ "Set-Cookie": `prism_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_MS / 1000}`,
232
+ });
233
+ return res.end(JSON.stringify({ ok: true }));
234
+ }
235
+ }
236
+ catch { /* fall through to 401 */ }
237
+ res.writeHead(401, { "Content-Type": "application/json" });
238
+ return res.end(JSON.stringify({ error: "Invalid credentials" }));
239
+ }
240
+ // ─── v5.1: Auth gate — block unauthenticated requests ───
241
+ if (AUTH_ENABLED && !isAuthenticated(req)) {
242
+ // For API calls, return 401 JSON
243
+ if (reqUrl.pathname.startsWith("/api/")) {
244
+ res.writeHead(401, { "Content-Type": "application/json" });
245
+ return res.end(JSON.stringify({ error: "Authentication required" }));
246
+ }
247
+ // For page requests, show login page
248
+ res.writeHead(401, { "Content-Type": "text/html; charset=utf-8" });
249
+ return res.end(renderLoginPage());
250
+ }
105
251
  try {
106
252
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
107
253
  // ─── Serve the Dashboard UI ───
@@ -255,8 +401,11 @@ export async function startDashboardServer() {
255
401
  res.writeHead(200, { "Content-Type": "application/json" });
256
402
  return res.end(JSON.stringify({ ok: true, role }));
257
403
  }
258
- // ─── API: Knowledge Graph Data (v2.3.0) ───
259
- if (url.pathname === "/api/graph") {
404
+ // ─── API: Knowledge Graph Data (v2.3.0 / v5.1) ───
405
+ if (url.pathname === "/api/graph" && req.method === "GET") {
406
+ const project = url.searchParams.get("project") || undefined;
407
+ const days = url.searchParams.get("days") || undefined;
408
+ const min_importance = url.searchParams.get("min_importance") || undefined;
260
409
  // Fetch recent ledger entries to build the graph
261
410
  // We look at the last 100 entries to keep the graph relevant but performant
262
411
  const s = await getStorageSafe();
@@ -264,11 +413,28 @@ export async function startDashboardServer() {
264
413
  res.writeHead(503, { "Content-Type": "application/json" });
265
414
  return res.end(JSON.stringify({ error: "Storage initializing..." }));
266
415
  }
267
- const entries = await s.getLedgerEntries({
268
- limit: "100",
416
+ const params = {
269
417
  order: "created_at.desc",
270
- select: "project,keywords",
271
- });
418
+ select: "project,keywords,created_at,importance",
419
+ };
420
+ if (!project && !days && !min_importance) {
421
+ params.limit = "30"; // Keep default small to prevent Vis.js stack overflow (426 nodes @ 100 entries)
422
+ }
423
+ else {
424
+ params.limit = "200"; // Bump limit when exploring specific filters (capped by frontend maxNodes)
425
+ }
426
+ if (project) {
427
+ params.project = `eq.${project}`;
428
+ }
429
+ if (days) {
430
+ const past = new Date();
431
+ past.setDate(past.getDate() - parseInt(days, 10));
432
+ params.created_at = `gte.${past.toISOString()}`;
433
+ }
434
+ if (min_importance) {
435
+ params.importance = `gte.${parseInt(min_importance, 10)}`;
436
+ }
437
+ const entries = await s.getLedgerEntries(params);
272
438
  // Deduplication sets for nodes and edges
273
439
  const nodes = [];
274
440
  const edges = [];
@@ -322,6 +488,80 @@ export async function startDashboardServer() {
322
488
  res.writeHead(200, { "Content-Type": "application/json" });
323
489
  return res.end(JSON.stringify({ nodes, edges }));
324
490
  }
491
+ // ─── API: Edit Knowledge Graph Node (v5.1) ───
492
+ // Surgically patches keywords in the session_ledger.
493
+ // Supports two operations:
494
+ // 1. RENAME: old keyword → new keyword across all entries
495
+ // 2. DELETE: remove a keyword from all entries (newId = null)
496
+ //
497
+ // HOW IT WORKS:
498
+ // - Reconstructs the full PostgREST-style keyword (e.g. cat:debugging)
499
+ // - Uses LIKE-based search to find candidate entries
500
+ // - Validates exact array membership in JS (prevents substring matches)
501
+ // - Idempotently strips or replaces the keyword via patchLedger()
502
+ //
503
+ // SECURITY: Protected by the v5.1 Dashboard Auth gate above.
504
+ if (url.pathname === "/api/graph/node" && req.method === "POST") {
505
+ try {
506
+ const body = await readBody(req);
507
+ const { oldId, newId, group } = JSON.parse(body || "{}");
508
+ if (!oldId || !group || (group !== "keyword" && group !== "category")) {
509
+ res.writeHead(400, { "Content-Type": "application/json" });
510
+ return res.end(JSON.stringify({ error: "Invalid request" }));
511
+ }
512
+ const s = await getStorageSafe();
513
+ if (!s) {
514
+ res.writeHead(503, { "Content-Type": "application/json" });
515
+ return res.end(JSON.stringify({ error: "Storage not ready" }));
516
+ }
517
+ // 1. Reconstruct the full string as stored in DB
518
+ // Categories are prefixed with "cat:" (e.g. cat:debugging)
519
+ // Keywords are stored as bare strings (e.g. authentication)
520
+ const searchKw = group === "category" ? `cat:${oldId}` : oldId;
521
+ const newKw = newId ? (group === "category" ? `cat:${newId}` : newId) : null;
522
+ // 2. Fetch all entries containing the old keyword (LIKE search)
523
+ // Note: LIKE '%auth%' would also match 'authentication',
524
+ // so we verify exact array membership in the JS loop below.
525
+ const entries = await s.getLedgerEntries({
526
+ keywords: `cs.{${searchKw}}`,
527
+ select: "id,keywords",
528
+ });
529
+ let updated = 0;
530
+ for (const entry of entries) {
531
+ // Parse keywords — handle both SQLite (JSON string) and Supabase (array)
532
+ let kws = [];
533
+ if (Array.isArray(entry.keywords))
534
+ kws = entry.keywords;
535
+ else if (typeof entry.keywords === "string") {
536
+ try {
537
+ kws = JSON.parse(entry.keywords);
538
+ }
539
+ catch {
540
+ continue;
541
+ }
542
+ }
543
+ // Exact match check — guards against substring false positives
544
+ if (!kws.includes(searchKw))
545
+ continue;
546
+ // Remove the old keyword
547
+ const newKws = kws.filter(k => k !== searchKw);
548
+ // If renaming (not deleting), add the new keyword (no duplicates)
549
+ if (newKw && !newKws.includes(newKw)) {
550
+ newKws.push(newKw);
551
+ }
552
+ // 3. Patch the entry — patchLedger handles JSON serialization
553
+ await s.patchLedger(entry.id, { keywords: newKws });
554
+ updated++;
555
+ }
556
+ res.writeHead(200, { "Content-Type": "application/json" });
557
+ return res.end(JSON.stringify({ ok: true, updated }));
558
+ }
559
+ catch (err) {
560
+ console.error("[Dashboard] Node edit error:", err);
561
+ res.writeHead(500, { "Content-Type": "application/json" });
562
+ return res.end(JSON.stringify({ error: "Edit failed" }));
563
+ }
564
+ }
325
565
  // ─── API: Hivemind Team Roster (v3.0) ───
326
566
  if (url.pathname === "/api/team") {
327
567
  const projectName = url.searchParams.get("project");
@@ -537,6 +777,93 @@ export async function startDashboardServer() {
537
777
  return res.end(JSON.stringify({ error: "Export failed" }));
538
778
  }
539
779
  }
780
+ // ─── API: Universal History Import (v5.2) ───
781
+ if (url.pathname === "/api/import" && req.method === "POST") {
782
+ try {
783
+ const body = await new Promise(resolve => {
784
+ let data = "";
785
+ req.on("data", c => data += c);
786
+ req.on("end", () => resolve(data));
787
+ });
788
+ const { path: filePath, format, project, dryRun } = JSON.parse(body || "{}");
789
+ if (!filePath) {
790
+ res.writeHead(400, { "Content-Type": "application/json" });
791
+ return res.end(JSON.stringify({ error: "path is required" }));
792
+ }
793
+ // Verify file exists before starting import
794
+ if (!fs.existsSync(filePath)) {
795
+ res.writeHead(400, { "Content-Type": "application/json" });
796
+ return res.end(JSON.stringify({ error: `File not found: ${filePath}` }));
797
+ }
798
+ const { universalImporter } = await import("../utils/universalImporter.js");
799
+ const result = await universalImporter({
800
+ path: filePath,
801
+ format: format || undefined,
802
+ project: project || undefined,
803
+ dryRun: !!dryRun,
804
+ verbose: false,
805
+ });
806
+ res.writeHead(200, { "Content-Type": "application/json" });
807
+ return res.end(JSON.stringify({
808
+ ok: true,
809
+ ...result,
810
+ message: `Imported ${result.conversationCount} conversations (${result.successCount} turns)${result.skipCount > 0 ? `, ${result.skipCount} skipped (dup)` : ""}${result.failCount > 0 ? `, ${result.failCount} failed` : ""}${dryRun ? " [DRY RUN]" : ""}`,
811
+ }));
812
+ }
813
+ catch (err) {
814
+ console.error("[Dashboard] Import error:", err);
815
+ res.writeHead(500, { "Content-Type": "application/json" });
816
+ return res.end(JSON.stringify({ error: err.message || "Import failed" }));
817
+ }
818
+ }
819
+ // ─── API: Universal History Import via File Upload (v5.2) ───
820
+ if (url.pathname === "/api/import-upload" && req.method === "POST") {
821
+ try {
822
+ const body = await new Promise(resolve => {
823
+ let data = "";
824
+ req.on("data", c => data += c);
825
+ req.on("end", () => resolve(data));
826
+ });
827
+ const { filename, content, format, project, dryRun } = JSON.parse(body || "{}");
828
+ if (!content || !filename) {
829
+ res.writeHead(400, { "Content-Type": "application/json" });
830
+ return res.end(JSON.stringify({ error: "filename and content are required" }));
831
+ }
832
+ // Write uploaded content to a temp file
833
+ const tmpDir = path.join(os.tmpdir(), "prism-import");
834
+ fs.mkdirSync(tmpDir, { recursive: true });
835
+ const tmpFile = path.join(tmpDir, `upload-${Date.now()}-${filename}`);
836
+ fs.writeFileSync(tmpFile, content, "utf-8");
837
+ try {
838
+ const { universalImporter } = await import("../utils/universalImporter.js");
839
+ const result = await universalImporter({
840
+ path: tmpFile,
841
+ format: format || undefined,
842
+ project: project || undefined,
843
+ dryRun: !!dryRun,
844
+ verbose: false,
845
+ });
846
+ res.writeHead(200, { "Content-Type": "application/json" });
847
+ return res.end(JSON.stringify({
848
+ ok: true,
849
+ ...result,
850
+ message: `Imported ${result.conversationCount} conversations (${result.successCount} turns)${result.skipCount > 0 ? `, ${result.skipCount} skipped (dup)` : ""}${result.failCount > 0 ? `, ${result.failCount} failed` : ""}${dryRun ? " [DRY RUN]" : ""} from ${filename}`,
851
+ }));
852
+ }
853
+ finally {
854
+ // Clean up temp file
855
+ try {
856
+ fs.unlinkSync(tmpFile);
857
+ }
858
+ catch { /* ignore */ }
859
+ }
860
+ }
861
+ catch (err) {
862
+ console.error("[Dashboard] Import upload error:", err);
863
+ res.writeHead(500, { "Content-Type": "application/json" });
864
+ return res.end(JSON.stringify({ error: err.message || "Import failed" }));
865
+ }
866
+ }
540
867
  // ─── 404 ───
541
868
  res.writeHead(404, { "Content-Type": "text/plain" });
542
869
  res.end("Not found");
@@ -547,19 +874,61 @@ export async function startDashboardServer() {
547
874
  res.end(JSON.stringify({ error: "Internal Server Error" }));
548
875
  }
549
876
  });
550
- // Gracefully handle port conflicts (non-fatal MCP server keeps running)
551
- httpServer.on("error", (err) => {
552
- if (err.code === "EADDRINUSE") {
553
- console.error(`[Dashboard] Port ${PORT} is in use Mind Palace disabled. ` +
554
- `Set PRISM_DASHBOARD_PORT to use a different port.`);
877
+ // ─── Resilient port binding with retry ───
878
+ // Wraps listen() in a Promise to detect EADDRINUSE failures and retry
879
+ // with a delay (gives OS time to release the port after killPortHolder).
880
+ // Falls back to PORT+1, PORT+2 if the preferred port is permanently taken.
881
+ const MAX_RETRIES = 3;
882
+ const RETRY_DELAY_MS = 500;
883
+ const tryListen = (port) => new Promise((resolve, reject) => {
884
+ const onError = (err) => {
885
+ httpServer.removeListener("error", onError);
886
+ reject(err);
887
+ };
888
+ httpServer.on("error", onError);
889
+ httpServer.listen(port, () => {
890
+ httpServer.removeListener("error", onError);
891
+ // Re-register a permanent error handler for runtime errors
892
+ httpServer.on("error", (err) => {
893
+ console.error(`[Dashboard] HTTP server error: ${err.message}`);
894
+ });
895
+ resolve(port);
896
+ });
897
+ });
898
+ let boundPort = PORT;
899
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
900
+ try {
901
+ boundPort = await tryListen(PORT + attempt);
902
+ break; // Success
555
903
  }
556
- else {
557
- console.error(`[Dashboard] HTTP server error: ${err.message}`);
904
+ catch (err) {
905
+ if (err.code === "EADDRINUSE") {
906
+ console.error(`[Dashboard] Port ${PORT + attempt} is in use (attempt ${attempt + 1}/${MAX_RETRIES}).`);
907
+ if (attempt < MAX_RETRIES - 1) {
908
+ // Wait for OS to release the port, then try next port
909
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
910
+ }
911
+ else {
912
+ console.error(`[Dashboard] All ports ${PORT}–${PORT + MAX_RETRIES - 1} in use — Mind Palace disabled. ` +
913
+ `Set PRISM_DASHBOARD_PORT to use a different port.`);
914
+ return; // Give up — MCP server keeps running
915
+ }
916
+ }
917
+ else {
918
+ console.error(`[Dashboard] HTTP server error: ${err.message}`);
919
+ return; // Non-retryable error
920
+ }
558
921
  }
559
- });
560
- httpServer.listen(PORT, () => {
561
- console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${PORT}`);
562
- });
922
+ }
923
+ // Write the active port to a file for discoverability
924
+ try {
925
+ const portFile = path.join(os.homedir(), ".prism-mcp", "dashboard.port");
926
+ fs.writeFileSync(portFile, String(boundPort), "utf8");
927
+ }
928
+ catch {
929
+ // Non-fatal — just means the user has to know the port
930
+ }
931
+ console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
563
932
  // ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
564
933
  async function runTtlSweep() {
565
934
  try {