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.
- package/README.md +408 -1306
- package/dist/dashboard/server.js +391 -22
- package/dist/dashboard/ui.js +363 -17
- package/dist/server.js +15 -2
- package/dist/storage/sqlite.js +277 -6
- package/dist/storage/supabase.js +58 -0
- package/dist/storage/supabaseMigrations.js +104 -1
- package/dist/tools/compactionHandler.js +17 -7
- package/dist/tools/index.js +2 -2
- package/dist/tools/sessionMemoryDefinitions.js +70 -0
- package/dist/tools/sessionMemoryHandlers.js +167 -9
- package/dist/utils/migration/claudeAdapter.js +131 -0
- package/dist/utils/migration/geminiAdapter.js +87 -0
- package/dist/utils/migration/openaiAdapter.js +88 -0
- package/dist/utils/migration/types.js +18 -0
- package/dist/utils/migration/utils.js +99 -0
- package/dist/utils/testUniversalImporter.js +10 -0
- package/dist/utils/turboquant.js +730 -0
- package/dist/utils/universalImporter.js +295 -0
- package/package.json +8 -4
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
|
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
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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 {
|