prism-mcp-server 5.1.0 → 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 +379 -1385
- package/dist/dashboard/server.js +151 -15
- package/dist/dashboard/ui.js +165 -1
- package/dist/storage/sqlite.js +30 -0
- package/dist/storage/supabaseMigrations.js +19 -1
- package/dist/tools/compactionHandler.js +17 -7
- package/dist/tools/sessionMemoryDefinitions.js +7 -0
- package/dist/tools/sessionMemoryHandlers.js +68 -4
- 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/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;
|
|
@@ -770,6 +777,93 @@ return false;}
|
|
|
770
777
|
return res.end(JSON.stringify({ error: "Export failed" }));
|
|
771
778
|
}
|
|
772
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
|
+
}
|
|
773
867
|
// ─── 404 ───
|
|
774
868
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
775
869
|
res.end("Not found");
|
|
@@ -780,19 +874,61 @@ return false;}
|
|
|
780
874
|
res.end(JSON.stringify({ error: "Internal Server Error" }));
|
|
781
875
|
}
|
|
782
876
|
});
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
|
788
903
|
}
|
|
789
|
-
|
|
790
|
-
|
|
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
|
+
}
|
|
791
921
|
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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}`);
|
|
796
932
|
// ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
|
|
797
933
|
async function runTtlSweep() {
|
|
798
934
|
try {
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -601,6 +601,53 @@ export function renderDashboardHTML(version) {
|
|
|
601
601
|
<div style="font-size:0.7rem;color:var(--text-muted);margin-top:0.4rem">0 = disabled. Min 7 days. Rollups are never expired.</div>
|
|
602
602
|
</div>
|
|
603
603
|
|
|
604
|
+
<!-- Universal History Import (v5.2) -->
|
|
605
|
+
<div class="card" id="importCard" style="display:none">
|
|
606
|
+
<div class="card-title"><span class="dot" style="background:var(--accent-cyan)"></span> Import History 📥</div>
|
|
607
|
+
<div style="margin-bottom:0.75rem">
|
|
608
|
+
<label style="font-size:0.78rem;color:var(--text-muted);display:block;margin-bottom:0.3rem">Source File</label>
|
|
609
|
+
<div style="display:flex;gap:0.4rem;align-items:center">
|
|
610
|
+
<input type="text" id="importPath" class="ttl-input" style="flex:1;text-align:left;font-size:0.82rem;padding:0.45rem 0.65rem" placeholder="/path/to/conversations.jsonl">
|
|
611
|
+
<input type="file" id="importFileInput" accept=".jsonl,.json,.ndjson" style="display:none">
|
|
612
|
+
<button class="lc-btn compact" onclick="document.getElementById('importFileInput').click()" style="flex:none;padding:0.45rem 0.75rem;font-size:0.82rem;white-space:nowrap" title="Choose a file from your computer">
|
|
613
|
+
📂 Browse
|
|
614
|
+
</button>
|
|
615
|
+
<button class="lc-btn" onclick="clearImportFile()" id="importClearBtn" style="flex:none;padding:0.45rem 0.55rem;font-size:0.82rem;display:none;background:rgba(244,63,94,0.15);border-color:rgba(244,63,94,0.3);color:var(--accent-rose)" title="Clear selection">
|
|
616
|
+
✕
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
619
|
+
<div id="importFileInfo" style="display:none;margin-top:0.35rem;font-size:0.72rem;color:var(--accent-cyan)"></div>
|
|
620
|
+
</div>
|
|
621
|
+
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap">
|
|
622
|
+
<div style="flex:1;min-width:120px">
|
|
623
|
+
<label style="font-size:0.78rem;color:var(--text-muted);display:block;margin-bottom:0.3rem">Format</label>
|
|
624
|
+
<select id="importFormat" class="ttl-input" style="width:100%;text-align:left;font-size:0.82rem;padding:0.35rem 0.5rem;cursor:pointer">
|
|
625
|
+
<option value="">Auto-detect</option>
|
|
626
|
+
<option value="claude">Claude Code (.jsonl)</option>
|
|
627
|
+
<option value="gemini">Gemini (.json)</option>
|
|
628
|
+
<option value="openai">OpenAI (.json)</option>
|
|
629
|
+
</select>
|
|
630
|
+
</div>
|
|
631
|
+
<div style="flex:1;min-width:120px">
|
|
632
|
+
<label style="font-size:0.78rem;color:var(--text-muted);display:block;margin-bottom:0.3rem">Target Project</label>
|
|
633
|
+
<input type="text" id="importProject" class="ttl-input" style="width:100%;text-align:left;font-size:0.82rem;padding:0.45rem 0.65rem" placeholder="(auto from file)">
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
<div style="display:flex;gap:0.5rem;align-items:center">
|
|
637
|
+
<button class="lc-btn compact" id="importBtn" onclick="runImport(false)" style="flex:1">
|
|
638
|
+
📥 Import
|
|
639
|
+
</button>
|
|
640
|
+
<button class="lc-btn export" id="importDryBtn" onclick="runImport(true)" style="flex:1" title="Validate without writing to storage">
|
|
641
|
+
🧪 Dry Run
|
|
642
|
+
</button>
|
|
643
|
+
</div>
|
|
644
|
+
<div id="importResult" style="display:none;margin-top:0.75rem;padding:0.65rem 0.85rem;border-radius:var(--radius-sm);font-size:0.82rem;line-height:1.5"></div>
|
|
645
|
+
<div style="font-size:0.68rem;color:var(--text-muted);margin-top:0.5rem">
|
|
646
|
+
Click <strong>Browse</strong> to pick a file, or type a server-side path.<br>
|
|
647
|
+
Supports Claude Code (.jsonl), Gemini (.json), and OpenAI (.json).
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
604
651
|
<div class="card" id="briefingCard" style="display:none">
|
|
605
652
|
<div class="card-title"><span class="dot" style="background:var(--accent-amber)"></span> Morning Briefing 🌅</div>
|
|
606
653
|
<div class="briefing-text" id="briefingText"></div>
|
|
@@ -1280,9 +1327,10 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
1280
1327
|
document.getElementById('content').className = 'grid grid-main fade-in';
|
|
1281
1328
|
document.getElementById('content').style.display = 'grid';
|
|
1282
1329
|
|
|
1283
|
-
// v3.1: Analytics + Lifecycle Controls
|
|
1330
|
+
// v3.1: Analytics + Lifecycle Controls + Import
|
|
1284
1331
|
document.getElementById('analyticsCard').style.display = 'block';
|
|
1285
1332
|
document.getElementById('lifecycleCard').style.display = 'block';
|
|
1333
|
+
document.getElementById('importCard').style.display = 'block';
|
|
1286
1334
|
loadAnalytics(project);
|
|
1287
1335
|
loadRetention(project);
|
|
1288
1336
|
|
|
@@ -1403,6 +1451,122 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
|
|
|
1403
1451
|
}
|
|
1404
1452
|
}
|
|
1405
1453
|
|
|
1454
|
+
// ─── v5.2: Universal History Import ───────────────────────────────
|
|
1455
|
+
|
|
1456
|
+
// Track the picked file for upload mode
|
|
1457
|
+
var _importPickedFile = null;
|
|
1458
|
+
|
|
1459
|
+
document.getElementById('importFileInput').addEventListener('change', function(e) {
|
|
1460
|
+
var file = e.target.files[0];
|
|
1461
|
+
if (!file) return;
|
|
1462
|
+
_importPickedFile = file;
|
|
1463
|
+
var pathInput = document.getElementById('importPath');
|
|
1464
|
+
pathInput.value = file.name;
|
|
1465
|
+
document.getElementById('importClearBtn').style.display = 'inline-flex';
|
|
1466
|
+
var infoEl = document.getElementById('importFileInfo');
|
|
1467
|
+
var sizeKB = (file.size / 1024).toFixed(1);
|
|
1468
|
+
var sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
|
1469
|
+
infoEl.textContent = '📄 ' + file.name + ' (' + (file.size > 1048576 ? sizeMB + ' MB' : sizeKB + ' KB') + ')';
|
|
1470
|
+
infoEl.style.display = 'block';
|
|
1471
|
+
|
|
1472
|
+
// Auto-detect format from extension
|
|
1473
|
+
var fmt = document.getElementById('importFormat');
|
|
1474
|
+
if (file.name.endsWith('.jsonl') || file.name.endsWith('.ndjson')) {
|
|
1475
|
+
fmt.value = 'claude';
|
|
1476
|
+
} else if (file.name.toLowerCase().includes('gemini')) {
|
|
1477
|
+
fmt.value = 'gemini';
|
|
1478
|
+
} else if (file.name.toLowerCase().includes('openai') || file.name.toLowerCase().includes('chatgpt')) {
|
|
1479
|
+
fmt.value = 'openai';
|
|
1480
|
+
} else {
|
|
1481
|
+
fmt.value = '';
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
function clearImportFile() {
|
|
1486
|
+
_importPickedFile = null;
|
|
1487
|
+
document.getElementById('importPath').value = '';
|
|
1488
|
+
document.getElementById('importFileInput').value = '';
|
|
1489
|
+
document.getElementById('importClearBtn').style.display = 'none';
|
|
1490
|
+
document.getElementById('importFileInfo').style.display = 'none';
|
|
1491
|
+
document.getElementById('importResult').style.display = 'none';
|
|
1492
|
+
document.getElementById('importFormat').value = '';
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function runImport(dryRun) {
|
|
1496
|
+
var filePath = document.getElementById('importPath').value.trim();
|
|
1497
|
+
if (!filePath && !_importPickedFile) { showToast('❌ Pick a file or enter a path', true); return; }
|
|
1498
|
+
|
|
1499
|
+
var format = document.getElementById('importFormat').value || undefined;
|
|
1500
|
+
var project = document.getElementById('importProject').value.trim() || undefined;
|
|
1501
|
+
var importBtn = document.getElementById('importBtn');
|
|
1502
|
+
var dryBtn = document.getElementById('importDryBtn');
|
|
1503
|
+
var resultEl = document.getElementById('importResult');
|
|
1504
|
+
|
|
1505
|
+
importBtn.disabled = true;
|
|
1506
|
+
dryBtn.disabled = true;
|
|
1507
|
+
var activeBtn = dryRun ? dryBtn : importBtn;
|
|
1508
|
+
var origText = activeBtn.innerHTML;
|
|
1509
|
+
activeBtn.innerHTML = dryRun ? '🔄 Validating...' : '🔄 Importing...';
|
|
1510
|
+
|
|
1511
|
+
resultEl.style.display = 'block';
|
|
1512
|
+
resultEl.style.background = 'rgba(139,92,246,0.1)';
|
|
1513
|
+
resultEl.style.border = '1px solid rgba(139,92,246,0.25)';
|
|
1514
|
+
resultEl.style.color = 'var(--accent-purple)';
|
|
1515
|
+
resultEl.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;margin-right:0.4rem;vertical-align:middle"></span> ' +
|
|
1516
|
+
(dryRun ? 'Validating file...' : 'Importing turns...');
|
|
1517
|
+
|
|
1518
|
+
try {
|
|
1519
|
+
var endpoint, body, headers;
|
|
1520
|
+
|
|
1521
|
+
if (_importPickedFile) {
|
|
1522
|
+
// Upload mode: read file and send as base64
|
|
1523
|
+
var content = await _importPickedFile.text();
|
|
1524
|
+
endpoint = '/api/import-upload';
|
|
1525
|
+
headers = {'Content-Type':'application/json'};
|
|
1526
|
+
body = JSON.stringify({
|
|
1527
|
+
filename: _importPickedFile.name,
|
|
1528
|
+
content: content,
|
|
1529
|
+
format: format,
|
|
1530
|
+
project: project,
|
|
1531
|
+
dryRun: dryRun
|
|
1532
|
+
});
|
|
1533
|
+
} else {
|
|
1534
|
+
// Path mode: just send the server-side path
|
|
1535
|
+
endpoint = '/api/import';
|
|
1536
|
+
headers = {'Content-Type':'application/json'};
|
|
1537
|
+
body = JSON.stringify({ path: filePath, format: format, project: project, dryRun: dryRun });
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
var res = await fetch(endpoint, { method: 'POST', headers: headers, body: body });
|
|
1541
|
+
var d = await res.json();
|
|
1542
|
+
if (res.ok && d.ok) {
|
|
1543
|
+
resultEl.style.background = 'rgba(16,185,129,0.1)';
|
|
1544
|
+
resultEl.style.border = '1px solid rgba(16,185,129,0.25)';
|
|
1545
|
+
resultEl.style.color = 'var(--accent-green)';
|
|
1546
|
+
resultEl.innerHTML = '✅ ' + escapeHtml(d.message) +
|
|
1547
|
+
'<div style="margin-top:0.4rem;font-size:0.75rem;color:var(--text-muted)">' +
|
|
1548
|
+
'Conversations: ' + (d.conversationCount || 0) + ' · Turns: ' + (d.successCount || 0) +
|
|
1549
|
+
(d.skipCount ? ' · Skipped: ' + d.skipCount : '') +
|
|
1550
|
+
(d.failCount ? ' · Failed: ' + d.failCount : '') + '</div>';
|
|
1551
|
+
if (!dryRun) { showToast('✓ Import complete'); loadProject(); }
|
|
1552
|
+
} else {
|
|
1553
|
+
resultEl.style.background = 'rgba(244,63,94,0.1)';
|
|
1554
|
+
resultEl.style.border = '1px solid rgba(244,63,94,0.25)';
|
|
1555
|
+
resultEl.style.color = 'var(--accent-rose)';
|
|
1556
|
+
resultEl.innerHTML = '❌ ' + escapeHtml(d.error || 'Import failed');
|
|
1557
|
+
}
|
|
1558
|
+
} catch(e) {
|
|
1559
|
+
resultEl.style.background = 'rgba(244,63,94,0.1)';
|
|
1560
|
+
resultEl.style.border = '1px solid rgba(244,63,94,0.25)';
|
|
1561
|
+
resultEl.style.color = 'var(--accent-rose)';
|
|
1562
|
+
resultEl.innerHTML = '❌ ' + escapeHtml(e.message);
|
|
1563
|
+
} finally {
|
|
1564
|
+
importBtn.disabled = false;
|
|
1565
|
+
dryBtn.disabled = false;
|
|
1566
|
+
activeBtn.innerHTML = origText;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1406
1570
|
function showToast(msg, isErr) {
|
|
1407
1571
|
var el = document.getElementById('fixedToast');
|
|
1408
1572
|
if (!el) return;
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -379,6 +379,20 @@ export class SqliteStorage {
|
|
|
379
379
|
if (!e.message?.includes("duplicate column name"))
|
|
380
380
|
throw e;
|
|
381
381
|
}
|
|
382
|
+
// ─── v5.2 Migration: Cognitive Memory — Last Accessed Tracking ───
|
|
383
|
+
//
|
|
384
|
+
// REVIEWER NOTE: last_accessed_at enables dynamic importance decay
|
|
385
|
+
// computed at retrieval time: effective = base * 0.95^days_since_access.
|
|
386
|
+
// No background workers needed — decay is a pure function of time.
|
|
387
|
+
// This column is updated fire-and-forget on each search hit.
|
|
388
|
+
try {
|
|
389
|
+
await this.db.execute(`ALTER TABLE session_ledger ADD COLUMN last_accessed_at TEXT DEFAULT NULL`);
|
|
390
|
+
debugLog("[SqliteStorage] v5.2 migration: added last_accessed_at column");
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
if (!e.message?.includes("duplicate column name"))
|
|
394
|
+
throw e;
|
|
395
|
+
}
|
|
382
396
|
}
|
|
383
397
|
// ─── PostgREST Filter Parser ───────────────────────────────
|
|
384
398
|
//
|
|
@@ -544,9 +558,25 @@ export class SqliteStorage {
|
|
|
544
558
|
return [{ id, project: entry.project, created_at: now }];
|
|
545
559
|
}
|
|
546
560
|
async patchLedger(id, data) {
|
|
561
|
+
// ── Column Allowlist (Defense-in-Depth) ────────────────────────
|
|
562
|
+
// Column names are interpolated directly into SQL (not parameterizable).
|
|
563
|
+
// This allowlist prevents accidental or malicious injection via the key.
|
|
564
|
+
// Currently, patchLedger is only called from internal handler code,
|
|
565
|
+
// but this guard protects against future misuse if the method is
|
|
566
|
+
// exposed to less-controlled callers.
|
|
567
|
+
const ALLOWED_COLUMNS = new Set([
|
|
568
|
+
'embedding', 'embedding_compressed', 'embedding_format', 'embedding_turbo_radius',
|
|
569
|
+
'archived_at', 'deleted_at', 'deleted_reason', 'is_rollup', 'rollup_count',
|
|
570
|
+
'importance', 'last_accessed_at', 'keywords', 'todos', 'files_changed', 'decisions',
|
|
571
|
+
'summary', 'confidence_score', 'event_type', 'role',
|
|
572
|
+
]);
|
|
547
573
|
const sets = [];
|
|
548
574
|
const args = [];
|
|
549
575
|
for (const [key, value] of Object.entries(data)) {
|
|
576
|
+
if (!ALLOWED_COLUMNS.has(key)) {
|
|
577
|
+
debugLog(`[SqliteStorage] patchLedger: rejected unknown column "${key}" — skipping`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
550
580
|
if (key === "embedding") {
|
|
551
581
|
// Use libSQL's native vector() function for F32_BLOB columns.
|
|
552
582
|
// The value is a JSON-stringified number[] from the handler.
|
|
@@ -182,7 +182,25 @@ export const MIGRATIONS = [
|
|
|
182
182
|
$$;
|
|
183
183
|
`,
|
|
184
184
|
},
|
|
185
|
-
|
|
185
|
+
{
|
|
186
|
+
// ─── v5.2: Cognitive Memory — Last Accessed Tracking ──────────
|
|
187
|
+
//
|
|
188
|
+
// REVIEWER NOTE: This column enables the Ebbinghaus Importance Decay
|
|
189
|
+
// feature (effective = base * 0.95^days_since_accessed) computed at
|
|
190
|
+
// retrieval time in sessionMemoryHandlers.ts. No background workers
|
|
191
|
+
// needed — decay is a pure function of time.
|
|
192
|
+
//
|
|
193
|
+
// The column is updated fire-and-forget via patchLedger() on every
|
|
194
|
+
// search hit. NULLs are expected (entries never retrieved yet) and
|
|
195
|
+
// the decay formula falls back to created_at when last_accessed_at
|
|
196
|
+
// is NULL.
|
|
197
|
+
version: 31,
|
|
198
|
+
name: "cognitive_memory_last_accessed",
|
|
199
|
+
sql: `
|
|
200
|
+
ALTER TABLE session_ledger ADD COLUMN IF NOT EXISTS last_accessed_at TIMESTAMPTZ DEFAULT NULL;
|
|
201
|
+
`,
|
|
202
|
+
},
|
|
203
|
+
// Future migrations go here (version 32+)
|
|
186
204
|
];
|
|
187
205
|
/**
|
|
188
206
|
* Current schema version — derived from the MIGRATIONS array.
|
|
@@ -23,13 +23,23 @@ async function summarizeEntries(entries) {
|
|
|
23
23
|
const entriesText = entries.map((e, i) => `[${i + 1}] ${e.session_date || "unknown date"}: ${e.summary || "no summary"}\n` +
|
|
24
24
|
(e.decisions?.length ? ` Decisions: ${e.decisions.join("; ")}\n` : "") +
|
|
25
25
|
(e.files_changed?.length ? ` Files: ${e.files_changed.join(", ")}\n` : "")).join("\n");
|
|
26
|
-
const prompt = (`You are compressing a session history log
|
|
27
|
-
`
|
|
28
|
-
`
|
|
29
|
-
`
|
|
30
|
-
`
|
|
31
|
-
`
|
|
32
|
-
`
|
|
26
|
+
const prompt = (`You are compressing a session history log for an AI agent's persistent memory.\n\n` +
|
|
27
|
+
`Analyze these ${entries.length} work sessions and produce THREE sections:\n\n` +
|
|
28
|
+
`1. SUMMARY (max 300 words): A concise paragraph preserving key decisions, ` +
|
|
29
|
+
`important file changes, error resolutions, and architecture changes. ` +
|
|
30
|
+
`Omit routine operations and intermediate debugging steps.\n\n` +
|
|
31
|
+
`2. PRINCIPLES (1-3 bullet points): Reusable lessons extracted from these sessions. ` +
|
|
32
|
+
`These should be actionable engineering insights the agent can apply to future work. ` +
|
|
33
|
+
`Format: "- [principle]"\n\n` +
|
|
34
|
+
`3. PATTERNS (1-3 bullet points): Recurring behaviors, tools, or workflows observed. ` +
|
|
35
|
+
`Format: "- [pattern]"\n\n` +
|
|
36
|
+
`Sessions to analyze:\n${entriesText}\n\n` +
|
|
37
|
+
`Output format (follow exactly):\n` +
|
|
38
|
+
`[summary paragraph]\n\n` +
|
|
39
|
+
`Principles:\n` +
|
|
40
|
+
`- ...\n\n` +
|
|
41
|
+
`Patterns:\n` +
|
|
42
|
+
`- ...`).substring(0, 30000);
|
|
33
43
|
return llm.generateText(prompt);
|
|
34
44
|
}
|
|
35
45
|
// ─── Main Handler ─────────────────────────────────────────────
|
|
@@ -303,6 +303,13 @@ export const SESSION_SEARCH_MEMORY_TOOL = {
|
|
|
303
303
|
description: "If true, returns a separate MEMORY TRACE content block with search strategy, " +
|
|
304
304
|
"latency breakdown (embedding vs storage), and scoring metadata. Default: false.",
|
|
305
305
|
},
|
|
306
|
+
// v5.2: Context-Weighted Retrieval — biases search toward active work context
|
|
307
|
+
context_boost: {
|
|
308
|
+
type: "boolean",
|
|
309
|
+
description: "If true, appends current project and working context to the search query " +
|
|
310
|
+
"before embedding generation, naturally biasing results toward contextually relevant memories. " +
|
|
311
|
+
"Useful when searching within a specific project context. Default: false.",
|
|
312
|
+
},
|
|
306
313
|
},
|
|
307
314
|
required: ["query"],
|
|
308
315
|
},
|
|
@@ -829,9 +829,12 @@ export async function sessionSearchMemoryHandler(args) {
|
|
|
829
829
|
const { query, project, limit = 5, similarity_threshold = 0.7,
|
|
830
830
|
// Phase 1: enable_trace defaults to false for full backward compatibility.
|
|
831
831
|
// When true, a MemoryTrace JSON block is appended as content[1].
|
|
832
|
-
enable_trace = false,
|
|
832
|
+
enable_trace = false,
|
|
833
|
+
// v5.2: Context-Weighted Retrieval — biases search toward active work context
|
|
834
|
+
context_boost = false, } = args;
|
|
833
835
|
debugLog(`[session_search_memory] Semantic search: query="${query}", ` +
|
|
834
|
-
`project=${project || "all"}, limit=${limit}, threshold=${similarity_threshold}`
|
|
836
|
+
`project=${project || "all"}, limit=${limit}, threshold=${similarity_threshold}` +
|
|
837
|
+
`${context_boost ? ", context_boost=ON" : ""}`);
|
|
835
838
|
// Phase 1: Start total latency timer BEFORE any work (embedding + storage)
|
|
836
839
|
const totalStart = performance.now();
|
|
837
840
|
// Step 1: Generate embedding for the search query
|
|
@@ -850,8 +853,43 @@ export async function sessionSearchMemoryHandler(args) {
|
|
|
850
853
|
// Phase 1: Start embedding latency timer — isolates Gemini API call time.
|
|
851
854
|
// This is the most variable component: 50ms on a good day, 2000ms under load.
|
|
852
855
|
const embeddingStart = performance.now();
|
|
856
|
+
// ── v5.2: Context-Weighted Retrieval ───────────────────────────
|
|
857
|
+
// When context_boost is enabled, prepend active project context to the
|
|
858
|
+
// search query before embedding generation. This naturally biases the
|
|
859
|
+
// embedding vector toward memories from the same project/branch/context.
|
|
860
|
+
// Elegant: no scoring heuristics needed — semantics do the work.
|
|
861
|
+
let effectiveQuery = query;
|
|
862
|
+
if (context_boost && project) {
|
|
863
|
+
try {
|
|
864
|
+
const storage = await getStorage();
|
|
865
|
+
const ctx = await storage.loadContext(project, "quick", PRISM_USER_ID);
|
|
866
|
+
const contextParts = [];
|
|
867
|
+
if (ctx && typeof ctx === "object") {
|
|
868
|
+
const ctxObj = ctx;
|
|
869
|
+
if (ctxObj.active_branch)
|
|
870
|
+
contextParts.push(`branch: ${ctxObj.active_branch}`);
|
|
871
|
+
if (ctxObj.key_context)
|
|
872
|
+
contextParts.push(`context: ${String(ctxObj.key_context).substring(0, 200)}`);
|
|
873
|
+
const keywords = ctxObj.keywords;
|
|
874
|
+
if (keywords?.length)
|
|
875
|
+
contextParts.push(`keywords: ${keywords.slice(0, 5).join(", ")}`);
|
|
876
|
+
}
|
|
877
|
+
if (contextParts.length > 0) {
|
|
878
|
+
effectiveQuery = `[${contextParts.join("; ")}] ${query}`;
|
|
879
|
+
debugLog(`[session_search_memory] Context boost applied: "${effectiveQuery.substring(0, 100)}..."`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// Context load failed — proceed with unmodified query (graceful degradation)
|
|
884
|
+
debugLog("[session_search_memory] Context boost failed (non-fatal) — using original query");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
else if (context_boost && !project) {
|
|
888
|
+
// User enabled context_boost but didn't specify a project — can't boost without context
|
|
889
|
+
debugLog("[session_search_memory] context_boost ignored — requires a project parameter to load context");
|
|
890
|
+
}
|
|
853
891
|
try {
|
|
854
|
-
queryEmbedding = await getLLMProvider().generateEmbedding(
|
|
892
|
+
queryEmbedding = await getLLMProvider().generateEmbedding(effectiveQuery);
|
|
855
893
|
}
|
|
856
894
|
catch (err) {
|
|
857
895
|
return {
|
|
@@ -912,14 +950,40 @@ export async function sessionSearchMemoryHandler(args) {
|
|
|
912
950
|
}
|
|
913
951
|
return { content: contentBlocks, isError: false };
|
|
914
952
|
}
|
|
915
|
-
//
|
|
953
|
+
// ── v5.2: Dynamic Importance Decay (Ebbinghaus Curve) ──────
|
|
954
|
+
// Compute effective_importance at retrieval time:
|
|
955
|
+
// effective = base_importance * 0.95^days_since_accessed
|
|
956
|
+
// This avoids background workers — decay is a pure function of time.
|
|
957
|
+
// Also fire-and-forget update last_accessed_at on all returned results.
|
|
958
|
+
const now = new Date();
|
|
959
|
+
const resultIds = results.map((r) => r.id).filter(Boolean);
|
|
960
|
+
// Fire-and-forget: update last_accessed_at for all returned results
|
|
961
|
+
if (resultIds.length > 0) {
|
|
962
|
+
const nowISO = now.toISOString();
|
|
963
|
+
for (const id of resultIds) {
|
|
964
|
+
storage.patchLedger(id, { last_accessed_at: nowISO }).catch(() => { });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Format results with similarity scores + effective importance
|
|
916
968
|
const formatted = results.map((r, i) => {
|
|
917
969
|
const score = typeof r.similarity === "number"
|
|
918
970
|
? `${(r.similarity * 100).toFixed(1)}%`
|
|
919
971
|
: "N/A";
|
|
972
|
+
// Dynamic importance decay: effective = base * 0.95^days
|
|
973
|
+
const baseImportance = r.importance ?? 0;
|
|
974
|
+
let effectiveImportance = baseImportance;
|
|
975
|
+
if (baseImportance > 0) {
|
|
976
|
+
const lastAccess = r.last_accessed_at || r.created_at || now.toISOString();
|
|
977
|
+
const daysSince = Math.max(0, (now.getTime() - new Date(lastAccess).getTime()) / 86400000);
|
|
978
|
+
effectiveImportance = Math.round(baseImportance * Math.pow(0.95, daysSince) * 100) / 100;
|
|
979
|
+
}
|
|
980
|
+
const importanceStr = baseImportance > 0
|
|
981
|
+
? ` Importance: ${effectiveImportance}${effectiveImportance !== baseImportance ? ` (base: ${baseImportance}, decayed)` : ""}\n`
|
|
982
|
+
: "";
|
|
920
983
|
return `[${i + 1}] ${score} similar — ${r.session_date || "unknown date"}\n` +
|
|
921
984
|
` Project: ${r.project}\n` +
|
|
922
985
|
` Summary: ${r.summary}\n` +
|
|
986
|
+
importanceStr +
|
|
923
987
|
(r.decisions?.length ? ` Decisions: ${r.decisions.join("; ")}\n` : "") +
|
|
924
988
|
(r.files_changed?.length ? ` Files: ${r.files_changed.join(", ")}\n` : "");
|
|
925
989
|
}).join("\n");
|