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.
@@ -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;
@@ -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
- // Gracefully handle port conflicts (non-fatal MCP server keeps running)
784
- httpServer.on("error", (err) => {
785
- if (err.code === "EADDRINUSE") {
786
- console.error(`[Dashboard] Port ${PORT} is in use Mind Palace disabled. ` +
787
- `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
788
903
  }
789
- else {
790
- 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
+ }
791
921
  }
792
- });
793
- httpServer.listen(PORT, () => {
794
- console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${PORT}`);
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 {
@@ -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;
@@ -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
- // Future migrations go here (version 31+)
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. Summarize these ${entries.length} ` +
27
- `work sessions into a single concise paragraph (max 500 words).\n\n` +
28
- `PRESERVE: key decisions, important file changes, error resolutions, ` +
29
- `architecture changes, and any recurring patterns.\n` +
30
- `OMIT: routine operations, intermediate debugging steps, and redundant details.\n\n` +
31
- `Sessions to summarize:\n${entriesText}\n\n` +
32
- `Provide ONLY the summary paragraph, no headers or formatting.`).substring(0, 30000);
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, } = args;
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(query);
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
- // Format results with similarity scores
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");