prism-mcp-server 3.0.1 โ†’ 3.1.1

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 CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## Table of Contents
16
16
 
17
- - [What's New (v3.0.1)](#whats-new-in-v301---agent-identity--brain-clean-up-)
17
+ - [What's New (v3.1.0)](#whats-new-in-v310---memory-lifecycle-)
18
18
  - [How Prism Compares](#how-prism-compares)
19
19
  - [Quick Start](#quick-start-zero-config--local-mode)
20
20
  - [Mind Palace Dashboard](#-the-mind-palace-dashboard)
@@ -39,7 +39,18 @@
39
39
 
40
40
  ---
41
41
 
42
- ## What's New in v3.0.1 โ€” Agent Identity & Brain Clean-up ๐Ÿงน
42
+ ## What's New in v3.1.0 โ€” Memory Lifecycle ๐Ÿ”„
43
+
44
+ | Feature | Description |
45
+ |---|---|
46
+ | ๐Ÿ“Š **Memory Analytics** | New **Memory Analytics** card in the dashboard โ€” 14-day sparkline chart, active sessions count, rollup savings, and average context richness. Powered by `getAnalytics()` on both SQLite and Supabase backends. |
47
+ | โณ **Automated Data Retention (TTL)** | Set a per-project data retention policy via `knowledge_set_retention` MCP tool or the dashboard **Lifecycle Controls** card. Entries older than the TTL are soft-deleted (GDPR-compliant `archived_at` tombstone) every 12 hours automatically. Rollups are never expired. Minimum 7 days to prevent accidental mass-delete. |
48
+ | ๐Ÿ—œ๏ธ **Smart Auto-Compaction** | After every `session_save_ledger`, Prism runs a background health check and triggers compaction automatically if the brain is degraded or unhealthy โ€” gated by `compaction_auto` setting and debounced per-project to prevent concurrent Gemini calls. **Compact Now** button also available in the dashboard. |
49
+ | ๐Ÿ“ฆ **PKM Export (Obsidian / Logseq)** | Export any project's full memory as a ZIP archive of Markdown files โ€” one file per session with YAML-like frontmatter, TODOs, decisions, files-changed, and `#hashtag` keywords. Includes an `_index.md` with `[[wikilink]]` references. Click **Export ZIP** in the dashboard Lifecycle Controls card. |
50
+ | ๐Ÿงช **Expanded Test Suite** | 37 new Vitest tests (95 total) โ€” covers analytics queries, TTL soft-delete idempotency, rollup preservation, `activeCompactions` Set memory-leak prevention, type guards, export Markdown structure, and TTL sweep scheduler contracts. |
51
+
52
+ <details>
53
+ <summary><strong>What's in v3.0.1 โ€” Agent Identity & Brain Clean-up ๐Ÿงน</strong></summary>
43
54
 
44
55
  | Feature | Description |
45
56
  |---|---|
@@ -48,6 +59,8 @@
48
59
  | ๐Ÿ“œ **Role-Scoped Skills** | Each agent role can have its own persistent skill/rules document stored in the dashboard (โš™๏ธ Settings โ†’ Skills). It is automatically injected into every `session_load_context` response so the agent boots with its rules pre-loaded. |
49
60
  | ๐Ÿ”ค **Resource Formatting Fix** | `memory://{project}/handoff` resources now render as formatted plain text (Last Summary, TODOs, Keywords) instead of a raw JSON blob โ€” readable in Claude Desktop's paperclip attach panel. |
50
61
 
62
+ </details>
63
+
51
64
  <details>
52
65
  <summary><strong>What's in v3.0.0 โ€” Agent Hivemind ๐Ÿ</strong></summary>
53
66
 
@@ -468,6 +481,12 @@ graph TB
468
481
  | `session_search_memory` | Vector similarity search across all sessions |
469
482
  | `session_compact_ledger` | Auto-compact old ledger entries via Gemini-powered summarization |
470
483
 
484
+ ### v3.1 Lifecycle Tools
485
+
486
+ | Tool | Purpose |
487
+ |------|---------|
488
+ | `knowledge_set_retention` | Set a per-project TTL retention policy (0 = disabled, min 7 days). Immediately expires overdue entries. |
489
+
471
490
  ### v2.0 Advanced Memory Tools
472
491
 
473
492
  | Tool | Purpose |
@@ -21,7 +21,8 @@ import { exec } from "child_process";
21
21
  import { getStorage } from "../storage/index.js";
22
22
  import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
23
23
  import { renderDashboardHTML } from "./ui.js";
24
- import { getAllSettings, setSetting } from "../storage/configStorage.js";
24
+ import { getAllSettings, setSetting, getSetting } from "../storage/configStorage.js";
25
+ import { compactLedgerHandler } from "../tools/compactionHandler.js";
25
26
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
26
27
  /** Read HTTP request body as string */
27
28
  function readBody(req) {
@@ -74,9 +75,24 @@ async function killPortHolder(port) {
74
75
  });
75
76
  }
76
77
  export async function startDashboardServer() {
77
- // Clean up any zombie dashboard process from a previous session
78
- await killPortHolder(PORT);
79
- const storage = await getStorage();
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(() => { });
82
+ // Lazy storage accessor โ€” returns null if storage isn't ready yet.
83
+ // API routes gracefully degrade with 503 instead of blocking startup.
84
+ let _storage = null;
85
+ const getStorageSafe = async () => {
86
+ if (_storage)
87
+ return _storage;
88
+ try {
89
+ _storage = await getStorage();
90
+ return _storage;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ };
80
96
  const httpServer = http.createServer(async (req, res) => {
81
97
  // CORS headers for local dev
82
98
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -98,7 +114,12 @@ export async function startDashboardServer() {
98
114
  }
99
115
  // โ”€โ”€โ”€ API: List all projects โ”€โ”€โ”€
100
116
  if (url.pathname === "/api/projects") {
101
- const projects = await storage.listProjects();
117
+ const s = await getStorageSafe();
118
+ if (!s) {
119
+ res.writeHead(503, { "Content-Type": "application/json" });
120
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
121
+ }
122
+ const projects = await s.listProjects();
102
123
  res.writeHead(200, { "Content-Type": "application/json" });
103
124
  return res.end(JSON.stringify({ projects }));
104
125
  }
@@ -109,15 +130,20 @@ export async function startDashboardServer() {
109
130
  res.writeHead(400, { "Content-Type": "application/json" });
110
131
  return res.end(JSON.stringify({ error: "Missing ?name= parameter" }));
111
132
  }
112
- const context = await storage.loadContext(projectName, "deep", PRISM_USER_ID);
113
- const ledger = await storage.getLedgerEntries({
133
+ const s = await getStorageSafe();
134
+ if (!s) {
135
+ res.writeHead(503, { "Content-Type": "application/json" });
136
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
137
+ }
138
+ const context = await s.loadContext(projectName, "deep", PRISM_USER_ID);
139
+ const ledger = await s.getLedgerEntries({
114
140
  project: `eq.${projectName}`,
115
141
  order: "created_at.desc",
116
142
  limit: "20",
117
143
  });
118
144
  let history = [];
119
145
  try {
120
- history = await storage.getHistory(projectName, PRISM_USER_ID, 10);
146
+ history = await s.getHistory(projectName, PRISM_USER_ID, 10);
121
147
  }
122
148
  catch {
123
149
  // History may not exist for all projects
@@ -129,7 +155,12 @@ export async function startDashboardServer() {
129
155
  if (url.pathname === "/api/health" && req.method === "GET") {
130
156
  try {
131
157
  const { runHealthCheck } = await import("../utils/healthCheck.js");
132
- const stats = await storage.getHealthStats(PRISM_USER_ID);
158
+ const s = await getStorageSafe();
159
+ if (!s) {
160
+ res.writeHead(503, { "Content-Type": "application/json" });
161
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
162
+ }
163
+ const stats = await s.getHealthStats(PRISM_USER_ID);
133
164
  const report = runHealthCheck(stats);
134
165
  res.writeHead(200, { "Content-Type": "application/json" });
135
166
  return res.end(JSON.stringify(report));
@@ -152,14 +183,19 @@ export async function startDashboardServer() {
152
183
  if (url.pathname === "/api/health/cleanup" && req.method === "POST") {
153
184
  try {
154
185
  const { runHealthCheck } = await import("../utils/healthCheck.js");
155
- const stats = await storage.getHealthStats(PRISM_USER_ID);
186
+ const s = await getStorageSafe();
187
+ if (!s) {
188
+ res.writeHead(503, { "Content-Type": "application/json" });
189
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
190
+ }
191
+ const stats = await s.getHealthStats(PRISM_USER_ID);
156
192
  const report = runHealthCheck(stats);
157
193
  // Collect orphaned handoff projects from the health issues
158
194
  const orphaned = stats.orphanedHandoffs || [];
159
195
  const cleaned = [];
160
196
  for (const { project } of orphaned) {
161
197
  try {
162
- await storage.deleteHandoff(project, PRISM_USER_ID);
198
+ await s.deleteHandoff(project, PRISM_USER_ID);
163
199
  cleaned.push(project);
164
200
  console.error(`[Dashboard] Cleaned up orphaned handoff: ${project}`);
165
201
  }
@@ -223,7 +259,12 @@ export async function startDashboardServer() {
223
259
  if (url.pathname === "/api/graph") {
224
260
  // Fetch recent ledger entries to build the graph
225
261
  // We look at the last 100 entries to keep the graph relevant but performant
226
- const entries = await storage.getLedgerEntries({
262
+ const s = await getStorageSafe();
263
+ if (!s) {
264
+ res.writeHead(503, { "Content-Type": "application/json" });
265
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
266
+ }
267
+ const entries = await s.getLedgerEntries({
227
268
  limit: "100",
228
269
  order: "created_at.desc",
229
270
  select: "project,keywords",
@@ -289,7 +330,12 @@ export async function startDashboardServer() {
289
330
  return res.end(JSON.stringify({ error: "Missing ?project= parameter" }));
290
331
  }
291
332
  try {
292
- const team = await storage.listTeam(projectName, PRISM_USER_ID);
333
+ const s = await getStorageSafe();
334
+ if (!s) {
335
+ res.writeHead(503, { "Content-Type": "application/json" });
336
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
337
+ }
338
+ const team = await s.listTeam(projectName, PRISM_USER_ID);
293
339
  res.writeHead(200, { "Content-Type": "application/json" });
294
340
  return res.end(JSON.stringify({ team }));
295
341
  }
@@ -330,6 +376,167 @@ export async function startDashboardServer() {
330
376
  return res.end(JSON.stringify({ error: "Invalid JSON body" }));
331
377
  }
332
378
  }
379
+ // โ”€โ”€โ”€ API: Memory Analytics (v3.1) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
380
+ if (url.pathname === "/api/analytics" && req.method === "GET") {
381
+ const projectName = url.searchParams.get("project");
382
+ if (!projectName) {
383
+ res.writeHead(400, { "Content-Type": "application/json" });
384
+ return res.end(JSON.stringify({ error: "Missing ?project= parameter" }));
385
+ }
386
+ try {
387
+ const s = await getStorageSafe();
388
+ if (!s) {
389
+ res.writeHead(503, { "Content-Type": "application/json" });
390
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
391
+ }
392
+ const analytics = await s.getAnalytics(projectName, PRISM_USER_ID);
393
+ res.writeHead(200, { "Content-Type": "application/json" });
394
+ return res.end(JSON.stringify(analytics));
395
+ }
396
+ catch (err) {
397
+ console.error("[Dashboard] Analytics error:", err);
398
+ res.writeHead(200, { "Content-Type": "application/json" });
399
+ return res.end(JSON.stringify({
400
+ totalEntries: 0, totalRollups: 0, rollupSavings: 0,
401
+ avgSummaryLength: 0, sessionsByDay: [],
402
+ }));
403
+ }
404
+ }
405
+ // โ”€โ”€โ”€ API: Retention (TTL) Settings (v3.1) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
406
+ // GET /api/retention?project= โ†’ current TTL setting
407
+ // POST /api/retention โ†’ { project, ttl_days } โ†’ saves + runs sweep
408
+ if (url.pathname === "/api/retention") {
409
+ if (req.method === "GET") {
410
+ const projectName = url.searchParams.get("project");
411
+ if (!projectName) {
412
+ res.writeHead(400, { "Content-Type": "application/json" });
413
+ return res.end(JSON.stringify({ error: "Missing ?project= parameter" }));
414
+ }
415
+ const ttlRaw = await getSetting(`ttl:${projectName}`, "0");
416
+ res.writeHead(200, { "Content-Type": "application/json" });
417
+ return res.end(JSON.stringify({ project: projectName, ttl_days: parseInt(ttlRaw, 10) || 0 }));
418
+ }
419
+ if (req.method === "POST") {
420
+ const body = await readBody(req);
421
+ const { project, ttl_days } = JSON.parse(body || "{}");
422
+ if (!project || ttl_days === undefined) {
423
+ res.writeHead(400, { "Content-Type": "application/json" });
424
+ return res.end(JSON.stringify({ error: "project and ttl_days required" }));
425
+ }
426
+ if (ttl_days > 0 && ttl_days < 7) {
427
+ res.writeHead(400, { "Content-Type": "application/json" });
428
+ return res.end(JSON.stringify({ error: "Minimum TTL is 7 days" }));
429
+ }
430
+ await setSetting(`ttl:${project}`, String(ttl_days));
431
+ let expired = 0;
432
+ if (ttl_days > 0) {
433
+ const s = await getStorageSafe();
434
+ if (!s) {
435
+ res.writeHead(503, { "Content-Type": "application/json" });
436
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
437
+ }
438
+ const result = await s.expireByTTL(project, ttl_days, PRISM_USER_ID);
439
+ expired = result.expired;
440
+ }
441
+ res.writeHead(200, { "Content-Type": "application/json" });
442
+ return res.end(JSON.stringify({ ok: true, project, ttl_days, expired }));
443
+ }
444
+ }
445
+ // โ”€โ”€โ”€ API: Compact Now (v3.1 โ€” Dashboard button) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
446
+ if (url.pathname === "/api/compact" && req.method === "POST") {
447
+ const body = await readBody(req);
448
+ const { project } = JSON.parse(body || "{}");
449
+ if (!project) {
450
+ res.writeHead(400, { "Content-Type": "application/json" });
451
+ return res.end(JSON.stringify({ error: "project required" }));
452
+ }
453
+ try {
454
+ const result = await compactLedgerHandler({ project });
455
+ res.writeHead(200, { "Content-Type": "application/json" });
456
+ return res.end(JSON.stringify({ ok: true, result }));
457
+ }
458
+ catch (err) {
459
+ console.error("[Dashboard] Compact error:", err);
460
+ res.writeHead(500, { "Content-Type": "application/json" });
461
+ return res.end(JSON.stringify({ ok: false, error: "Compaction failed" }));
462
+ }
463
+ }
464
+ // โ”€โ”€โ”€ API: PKM Export โ€” Obsidian/Logseq ZIP (v3.1) โ”€โ”€โ”€โ”€โ”€โ”€
465
+ if (url.pathname === "/api/export" && req.method === "GET") {
466
+ const projectName = url.searchParams.get("project");
467
+ if (!projectName) {
468
+ res.writeHead(400, { "Content-Type": "application/json" });
469
+ return res.end(JSON.stringify({ error: "Missing ?project= parameter" }));
470
+ }
471
+ try {
472
+ // Lazy-import fflate to keep startup fast
473
+ const { strToU8, zipSync } = await import("fflate");
474
+ // Fetch all active ledger entries for this project
475
+ const s = await getStorageSafe();
476
+ if (!s) {
477
+ res.writeHead(503, { "Content-Type": "application/json" });
478
+ return res.end(JSON.stringify({ error: "Storage initializing..." }));
479
+ }
480
+ const entries = await s.getLedgerEntries({
481
+ project: `eq.${projectName}`,
482
+ order: "created_at.asc",
483
+ limit: "1000",
484
+ });
485
+ const files = {};
486
+ // One MD file per session
487
+ for (const entry of entries) {
488
+ const date = entry.created_at?.slice(0, 10) ?? "unknown";
489
+ const id = entry.id?.slice(0, 8) ?? "xxxxxxxx";
490
+ const filename = `${projectName}/${date}-${id}.md`;
491
+ const todos = Array.isArray(entry.todos) ? entry.todos : [];
492
+ const decisions = Array.isArray(entry.decisions) ? entry.decisions : [];
493
+ const files_changed = Array.isArray(entry.files_changed) ? entry.files_changed : [];
494
+ const tags = (Array.isArray(entry.keywords) ? entry.keywords : []).slice(0, 10);
495
+ const content = [
496
+ `# Session: ${date}`,
497
+ ``,
498
+ `**Project:** ${projectName}`,
499
+ `**Date:** ${date}`,
500
+ `**Role:** ${entry.role || "global"}`,
501
+ tags.length ? `**Tags:** ${tags.map(t => `#${t.replace(/\s+/g, "_")}`).join(" ")}` : "",
502
+ ``,
503
+ `## Summary`,
504
+ ``,
505
+ entry.summary,
506
+ ``,
507
+ todos.length ? `## TODOs\n\n${todos.map(t => `- [ ] ${t}`).join("\n")}` : "",
508
+ decisions.length ? `## Decisions\n\n${decisions.map(d => `- ${d}`).join("\n")}` : "",
509
+ files_changed.length ? `## Files Changed\n\n${files_changed.map(f => `- \`${f}\``).join("\n")}` : "",
510
+ ].filter(Boolean).join("\n");
511
+ files[filename] = strToU8(content);
512
+ }
513
+ // Index file linking all sessions
514
+ const indexLines = [
515
+ `# ${projectName} โ€” Session Index`,
516
+ ``,
517
+ `> Exported from Prism MCP on ${new Date().toISOString().slice(0, 10)}`,
518
+ ``,
519
+ ...entries.map(e => {
520
+ const d = e.created_at?.slice(0, 10) ?? "unknown";
521
+ const i = e.id?.slice(0, 8) ?? "xxxxxxxx";
522
+ return `- [[${projectName}/${d}-${i}]]`;
523
+ }),
524
+ ];
525
+ files[`${projectName}/_index.md`] = strToU8(indexLines.join("\n"));
526
+ const zipped = zipSync(files, { level: 6 });
527
+ res.writeHead(200, {
528
+ "Content-Type": "application/zip",
529
+ "Content-Disposition": `attachment; filename="prism-export-${projectName}-${Date.now()}.zip"`,
530
+ "Content-Length": String(zipped.byteLength),
531
+ });
532
+ return res.end(Buffer.from(zipped));
533
+ }
534
+ catch (err) {
535
+ console.error("[Dashboard] PKM export error:", err);
536
+ res.writeHead(500, { "Content-Type": "application/json" });
537
+ return res.end(JSON.stringify({ error: "Export failed" }));
538
+ }
539
+ }
333
540
  // โ”€โ”€โ”€ 404 โ”€โ”€โ”€
334
541
  res.writeHead(404, { "Content-Type": "text/plain" });
335
542
  res.end("Not found");
@@ -353,4 +560,31 @@ export async function startDashboardServer() {
353
560
  httpServer.listen(PORT, () => {
354
561
  console.error(`[Prism] ๐Ÿง  Mind Palace Dashboard โ†’ http://localhost:${PORT}`);
355
562
  });
563
+ // โ”€โ”€โ”€ v3.1: TTL Sweep โ€” runs at startup + every 12 hours โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
564
+ async function runTtlSweep() {
565
+ try {
566
+ const allSettings = await getAllSettings();
567
+ for (const [key, val] of Object.entries(allSettings)) {
568
+ if (!key.startsWith("ttl:"))
569
+ continue;
570
+ const project = key.replace("ttl:", "");
571
+ const ttlDays = parseInt(val, 10);
572
+ if (!ttlDays || ttlDays <= 0)
573
+ continue;
574
+ const s = await getStorageSafe();
575
+ if (!s)
576
+ continue;
577
+ const result = await s.expireByTTL(project, ttlDays, PRISM_USER_ID);
578
+ if (result.expired > 0) {
579
+ console.error(`[Dashboard] TTL sweep: expired ${result.expired} entries for "${project}" (ttl=${ttlDays}d)`);
580
+ }
581
+ }
582
+ }
583
+ catch (err) {
584
+ console.error("[Dashboard] TTL sweep error (non-fatal):", err);
585
+ }
586
+ }
587
+ // Run immediately on startup, then every 12 hours
588
+ runTtlSweep().catch(() => { });
589
+ setInterval(() => { runTtlSweep().catch(() => { }); }, 12 * 60 * 60 * 1000);
356
590
  }
@@ -444,6 +444,54 @@ export function renderDashboardHTML(version) {
444
444
  flex-shrink: 0; animation: pulseDot 2s ease-in-out infinite;
445
445
  }
446
446
  @keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
447
+
448
+ /* โ”€โ”€โ”€ Memory Analytics (v3.1) โ”€โ”€โ”€ */
449
+ .sparkline {
450
+ display: flex; align-items: flex-end; gap: 3px;
451
+ height: 48px; margin: 0.75rem 0 0.25rem;
452
+ }
453
+ .spark-bar {
454
+ flex: 1; background: rgba(139,92,246,0.35);
455
+ border-radius: 3px 3px 0 0; min-height: 3px;
456
+ transition: background 0.2s;
457
+ }
458
+ .spark-bar:hover { background: var(--accent-purple); }
459
+ .analytics-stats {
460
+ display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;
461
+ margin-top: 0.75rem;
462
+ }
463
+ .astat {
464
+ background: rgba(15,23,42,0.5); border-radius: var(--radius-sm);
465
+ padding: 0.6rem 0.75rem; display: flex; flex-direction: column; gap: 0.15rem;
466
+ }
467
+ .astat-val { font-size: 1.1rem; font-weight: 700; color: var(--accent-purple); font-family: var(--font-mono); }
468
+ .astat-label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
469
+
470
+ /* โ”€โ”€โ”€ Lifecycle Controls (v3.1) โ”€โ”€โ”€ */
471
+ .lc-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }
472
+ .lc-btn {
473
+ flex: 1; padding: 0.5rem 0.6rem; font-size: 0.8rem; font-weight: 600;
474
+ border-radius: var(--radius-sm); border: none; cursor: pointer;
475
+ transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.4rem;
476
+ }
477
+ .lc-btn.compact { background: rgba(139,92,246,0.15); color: var(--accent-purple); border: 1px solid rgba(139,92,246,0.3); }
478
+ .lc-btn.compact:hover { background: rgba(139,92,246,0.3); }
479
+ .lc-btn.export { background: rgba(16,185,129,0.12); color: var(--accent-green); border: 1px solid rgba(16,185,129,0.3); }
480
+ .lc-btn.export:hover { background: rgba(16,185,129,0.25); }
481
+ .lc-btn:disabled { opacity: 0.5; cursor: not-allowed; }
482
+ .ttl-row { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; }
483
+ .ttl-input {
484
+ width: 70px; background: var(--bg-secondary); border: 1px solid var(--border-glass);
485
+ color: var(--text-primary); border-radius: 6px; padding: 0.3rem 0.5rem;
486
+ font-size: 0.82rem; font-family: var(--font-mono); text-align: center;
487
+ }
488
+ .ttl-label { font-size: 0.8rem; color: var(--text-secondary); }
489
+ .ttl-save-btn {
490
+ margin-left: auto; padding: 0.3rem 0.75rem; font-size: 0.78rem; font-weight: 600;
491
+ background: rgba(245,158,11,0.15); color: var(--accent-amber);
492
+ border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; cursor: pointer; transition: all 0.2s;
493
+ }
494
+ .ttl-save-btn:hover { background: rgba(245,158,11,0.3); }
447
495
  </style>
448
496
  </head>
449
497
  <body>
@@ -509,7 +557,42 @@ export function renderDashboardHTML(version) {
509
557
  <div class="health-issues" id="healthIssues"></div>
510
558
  </div>
511
559
 
512
- <!-- Morning Briefing -->
560
+ <!-- Memory Analytics (v3.1) -->
561
+ <div class="card" id="analyticsCard" style="display:none">
562
+ <div class="card-title">
563
+ <span class="dot" style="background:var(--accent-purple)"></span>
564
+ Memory Analytics ๐Ÿ“Š
565
+ </div>
566
+ <div class="sparkline" id="sparkline" title="Sessions per day (last 14 days)"></div>
567
+ <div style="font-size:0.68rem;color:var(--text-muted);text-align:right">Sessions / day (14d)</div>
568
+ <div class="analytics-stats">
569
+ <div class="astat"><div class="astat-val" id="astat-entries">โ€”</div><div class="astat-label">Active sessions</div></div>
570
+ <div class="astat"><div class="astat-val" id="astat-rollups">โ€”</div><div class="astat-label">Rollups</div></div>
571
+ <div class="astat"><div class="astat-val" id="astat-savings">โ€”</div><div class="astat-label">Entries saved</div></div>
572
+ <div class="astat"><div class="astat-val" id="astat-avglen">โ€”</div><div class="astat-label">Avg summary chars</div></div>
573
+ </div>
574
+ </div>
575
+
576
+ <!-- Lifecycle Controls (v3.1) -->
577
+ <div class="card" id="lifecycleCard" style="display:none">
578
+ <div class="card-title"><span class="dot" style="background:var(--accent-amber)"></span> Lifecycle Controls โš™๏ธ</div>
579
+ <div class="lc-row">
580
+ <button class="lc-btn compact" id="compactBtn" onclick="compactNow()">
581
+ ๐Ÿ—œ๏ธ Compact Now
582
+ </button>
583
+ <button class="lc-btn export" id="exportBtn" onclick="exportPKM()">
584
+ ๐Ÿ“ฆ Export ZIP
585
+ </button>
586
+ </div>
587
+ <div class="ttl-row">
588
+ <span class="ttl-label">Auto-expire after</span>
589
+ <input type="number" class="ttl-input" id="ttlInput" min="0" max="3650" placeholder="0" title="Days. 0 = disabled">
590
+ <span class="ttl-label">days</span>
591
+ <button class="ttl-save-btn" onclick="saveTTL()">Save TTL</button>
592
+ </div>
593
+ <div style="font-size:0.7rem;color:var(--text-muted);margin-top:0.4rem">0 = disabled. Min 7 days. Rollups are never expired.</div>
594
+ </div>
595
+
513
596
  <div class="card" id="briefingCard" style="display:none">
514
597
  <div class="card-title"><span class="dot" style="background:var(--accent-amber)"></span> Morning Briefing ๐ŸŒ…</div>
515
598
  <div class="briefing-text" id="briefingText"></div>
@@ -894,6 +977,13 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
894
977
 
895
978
  document.getElementById('content').className = 'grid grid-main fade-in';
896
979
  document.getElementById('content').style.display = 'grid';
980
+
981
+ // v3.1: Analytics + Lifecycle Controls
982
+ document.getElementById('analyticsCard').style.display = 'block';
983
+ document.getElementById('lifecycleCard').style.display = 'block';
984
+ loadAnalytics(project);
985
+ loadRetention(project);
986
+
897
987
  loadTeam(); // v3.0: auto-load Hivemind team
898
988
  } catch(e) {
899
989
  alert('Failed to load project data: ' + e.message);
@@ -902,6 +992,126 @@ Example:\n## Dev Rules\n- Always write tests first\n- Use TypeScript strict mode
902
992
  }
903
993
  }
904
994
 
995
+ // โ”€โ”€โ”€ v3.1: Memory Analytics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
996
+ async function loadAnalytics(project) {
997
+ try {
998
+ var res = await fetch('/api/analytics?project=' + encodeURIComponent(project));
999
+ var d = await res.json();
1000
+
1001
+ document.getElementById('astat-entries').textContent = (d.totalEntries || 0);
1002
+ document.getElementById('astat-rollups').textContent = (d.totalRollups || 0);
1003
+ document.getElementById('astat-savings').textContent = (d.rollupSavings || 0);
1004
+ document.getElementById('astat-avglen').textContent = Math.round(d.avgSummaryLength || 0);
1005
+
1006
+ // Sparkline
1007
+ var sparkEl = document.getElementById('sparkline');
1008
+ var days = d.sessionsByDay || [];
1009
+ if (days.length === 0) {
1010
+ // Pad with 14 zero days
1011
+ days = Array.from({length:14}, function(_, i) {
1012
+ var dt = new Date(); dt.setDate(dt.getDate() - (13 - i));
1013
+ return { date: dt.toISOString().slice(0,10), count: 0 };
1014
+ });
1015
+ }
1016
+ var maxCount = Math.max.apply(null, days.map(function(x){return x.count || 0;})) || 1;
1017
+ sparkEl.innerHTML = days.slice(-14).map(function(d) {
1018
+ var pct = Math.max(4, Math.round(((d.count || 0) / maxCount) * 100));
1019
+ return '<div class="spark-bar" style="height:' + pct + '%" title="' + d.date + ': ' + d.count + '"></div>';
1020
+ }).join('');
1021
+ } catch(e) {
1022
+ console.warn('Analytics load failed:', e);
1023
+ }
1024
+ }
1025
+
1026
+ // โ”€โ”€โ”€ v3.1: TTL Retention โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1027
+ async function loadRetention(project) {
1028
+ try {
1029
+ var res = await fetch('/api/retention?project=' + encodeURIComponent(project));
1030
+ var d = await res.json();
1031
+ var inp = document.getElementById('ttlInput');
1032
+ if (inp) inp.value = d.ttl_days || 0;
1033
+ } catch(e) {}
1034
+ }
1035
+
1036
+ async function saveTTL() {
1037
+ var project = document.getElementById('projectSelect').value;
1038
+ if (!project) return;
1039
+ var days = parseInt(document.getElementById('ttlInput').value, 10) || 0;
1040
+ try {
1041
+ var res = await fetch('/api/retention', {
1042
+ method: 'POST',
1043
+ headers: {'Content-Type':'application/json'},
1044
+ body: JSON.stringify({ project, ttl_days: days })
1045
+ });
1046
+ var d = await res.json();
1047
+ if (d.ok) {
1048
+ showToast(days > 0 ? 'โœ“ TTL saved: ' + days + 'd (expired ' + (d.expired || 0) + ')' : 'โœ“ TTL disabled');
1049
+ } else {
1050
+ showToast('โŒ ' + (d.error || 'Save failed'), true);
1051
+ }
1052
+ } catch(e) { showToast('โŒ Cannot save TTL', true); }
1053
+ }
1054
+
1055
+ // โ”€โ”€โ”€ v3.1: Compact Now โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1056
+ async function compactNow() {
1057
+ var project = document.getElementById('projectSelect').value;
1058
+ if (!project) return;
1059
+ var btn = document.getElementById('compactBtn');
1060
+ btn.disabled = true;
1061
+ btn.textContent = '๐Ÿ—œ๏ธ Compacting...';
1062
+ try {
1063
+ var res = await fetch('/api/compact', {
1064
+ method: 'POST',
1065
+ headers: {'Content-Type':'application/json'},
1066
+ body: JSON.stringify({ project })
1067
+ });
1068
+ var d = await res.json();
1069
+ if (d.ok) {
1070
+ showToast('โœ“ Compaction done');
1071
+ loadAnalytics(project); // refresh stats
1072
+ } else {
1073
+ showToast('โŒ Compaction failed', true);
1074
+ }
1075
+ } catch(e) { showToast('โŒ ' + e.message, true); }
1076
+ finally {
1077
+ btn.disabled = false;
1078
+ btn.textContent = '๐Ÿ—œ๏ธ Compact Now';
1079
+ }
1080
+ }
1081
+
1082
+ // โ”€โ”€โ”€ v3.1: PKM Export (Obsidian / Logseq ZIP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1083
+ async function exportPKM() {
1084
+ var project = document.getElementById('projectSelect').value;
1085
+ if (!project) return;
1086
+ var btn = document.getElementById('exportBtn');
1087
+ btn.disabled = true;
1088
+ btn.textContent = '๐Ÿ“ฆ Exporting...';
1089
+ try {
1090
+ var a = document.createElement('a');
1091
+ a.href = '/api/export?project=' + encodeURIComponent(project);
1092
+ a.download = 'prism-export-' + project + '.zip';
1093
+ document.body.appendChild(a);
1094
+ a.click();
1095
+ document.body.removeChild(a);
1096
+ showToast('โ†“ Download started');
1097
+ } catch(e) { showToast('โŒ Export failed', true); }
1098
+ finally {
1099
+ btn.disabled = false;
1100
+ btn.textContent = '๐Ÿ“ฆ Export ZIP';
1101
+ }
1102
+ }
1103
+
1104
+ function showToast(msg, isErr) {
1105
+ var el = document.getElementById('fixedToast');
1106
+ if (!el) return;
1107
+ el.textContent = msg;
1108
+ el.style.borderColor = isErr ? 'rgba(244,63,94,0.4)' : 'var(--border-glow)';
1109
+ el.style.color = isErr ? 'var(--accent-rose)' : 'var(--text-primary)';
1110
+ el.classList.add('show');
1111
+ clearTimeout(el._t);
1112
+ el._t = setTimeout(function(){ el.classList.remove('show'); }, 3000);
1113
+ }
1114
+
905
1115
  function escapeHtml(str) {
906
1116
  if (!str) return '';
907
1117
  return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');