prism-mcp-server 3.0.1 → 3.1.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/dist/server.js CHANGED
@@ -68,6 +68,7 @@ import { startDashboardServer } from "./dashboard/server.js";
68
68
  // error wrapper. Now uses getStorage() which routes through the
69
69
  // correct backend (Supabase or SQLite) with proper error handling.
70
70
  import { getStorage } from "./storage/index.js";
71
+ import { getSettingSync, initConfigStorage } from "./storage/configStorage.js";
71
72
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
72
73
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
73
74
  // Session memory tools — only used if Supabase is configured
@@ -81,7 +82,9 @@ SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL,
81
82
  // ─── v2.2.0: Health Check tool definition ───
82
83
  SESSION_HEALTH_CHECK_TOOL,
83
84
  // ─── Phase 2: GDPR Memory Deletion tool definition ───
84
- SESSION_FORGET_MEMORY_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler,
85
+ SESSION_FORGET_MEMORY_TOOL,
86
+ // ─── v3.1: TTL Retention tool ───
87
+ KNOWLEDGE_SET_RETENTION_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler,
85
88
  // ─── v0.4.0: New tool handlers ───
86
89
  compactLedgerHandler, sessionSearchMemoryHandler,
87
90
  // ─── v2.0: Time Travel handlers ───
@@ -92,6 +95,8 @@ sessionSaveImageHandler, sessionViewImageHandler,
92
95
  sessionHealthCheckHandler,
93
96
  // ─── Phase 2: GDPR Memory Deletion handler ───
94
97
  sessionForgetMemoryHandler,
98
+ // ─── v3.1: TTL Retention handler ───
99
+ knowledgeSetRetentionHandler,
95
100
  // ─── v3.0: Agent Hivemind tools ───
96
101
  AGENT_REGISTRY_TOOLS, agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler, } from "./tools/index.js";
97
102
  // ─── Dynamic Tool Registration ───────────────────────────────────
@@ -126,6 +131,8 @@ const SESSION_MEMORY_TOOLS = [
126
131
  SESSION_HEALTH_CHECK_TOOL, // session_health_check — brain integrity checker (v2.2.0)
127
132
  // ─── Phase 2: GDPR Memory Deletion tool ───
128
133
  SESSION_FORGET_MEMORY_TOOL, // session_forget_memory — GDPR-compliant memory deletion (Phase 2)
134
+ // ─── v3.1: TTL Retention tool ───
135
+ KNOWLEDGE_SET_RETENTION_TOOL, // knowledge_set_retention — set auto-expiry TTL for a project
129
136
  ];
130
137
  // Combine: always list ALL tools so scanners (Glama, Smithery, MCP Registry)
131
138
  // can enumerate the full capability set. Runtime guards in the CallTool handler
@@ -146,6 +153,11 @@ const ALL_TOOLS = [
146
153
  // This is a simple in-memory set. If the server restarts, clients
147
154
  // will re-subscribe on reconnect (per MCP spec behavior).
148
155
  const activeSubscriptions = new Set();
156
+ // Module-level promise for the async storage pre-warm fired in startServer().
157
+ // Resource handlers check storageIsReady (synchronous) instead of awaiting
158
+ // the promise, so they never block the MCP stdio pipe during startup.
159
+ let storageReady = null;
160
+ let storageIsReady = false;
149
161
  /**
150
162
  * Notifies subscribed clients that a resource has changed.
151
163
  *
@@ -260,6 +272,20 @@ export function createServer() {
260
272
  if (name !== "resume_session") {
261
273
  throw new Error(`Unknown prompt: ${name}`);
262
274
  }
275
+ // Non-blocking: if storage isn't warm yet, return a fallback message
276
+ // instead of blocking the MCP stdio pipe during Supabase init.
277
+ if (!storageIsReady) {
278
+ const project = promptArgs?.project || "default";
279
+ return {
280
+ messages: [{
281
+ role: "user",
282
+ content: {
283
+ type: "text",
284
+ text: `⏳ Storage is still initializing. Session context for "${project}" will be available shortly.\nUse the session_load_context tool to load context once ready.`,
285
+ },
286
+ }],
287
+ };
288
+ }
263
289
  const project = promptArgs?.project || "default";
264
290
  const level = promptArgs?.level || "standard";
265
291
  // v2.3.6 FIX: Use storage abstraction instead of direct supabaseRpc
@@ -330,9 +356,14 @@ export function createServer() {
330
356
  }));
331
357
  // List concrete resources — one per known project
332
358
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
333
- // v2.3.6 FIX: Use storage abstraction instead of direct supabaseGet
359
+ // Non-blocking: if storage isn't warm yet, return empty list instantly
360
+ // so the client UI isn't blocked during Supabase init (can take 1m+).
361
+ // Resources will appear on the next ListResources call once warm.
362
+ if (!storageIsReady) {
363
+ return { resources: [] };
364
+ }
334
365
  try {
335
- const storage = await getStorage();
366
+ const storage = await getStorage(); // instant — singleton is warm
336
367
  const projects = await storage.listProjects();
337
368
  return {
338
369
  resources: projects.map((p) => ({
@@ -357,8 +388,19 @@ export function createServer() {
357
388
  throw new Error(`Unknown resource URI: ${uri}. Expected format: memory://{project}/handoff`);
358
389
  }
359
390
  const project = decodeURIComponent(match[1]);
391
+ // Non-blocking: if storage isn't warm yet, return a friendly fallback
392
+ // instead of blocking the client UI for 1m+ during Supabase init.
393
+ if (!storageIsReady) {
394
+ return {
395
+ contents: [{
396
+ uri,
397
+ mimeType: "text/plain",
398
+ text: `⏳ Storage is still initializing. Session context for "${project}" will be available shortly.\nUse the session_load_context tool to load context once ready.`,
399
+ }],
400
+ };
401
+ }
360
402
  try {
361
- const storage = await getStorage();
403
+ const storage = await getStorage(); // instant — singleton is warm
362
404
  const data = await storage.loadContext(project, "standard", PRISM_USER_ID);
363
405
  if (!data) {
364
406
  return {
@@ -396,11 +438,25 @@ export function createServer() {
396
438
  ? `\n\n🔑 Session version: ${version}. Pass expected_version: ${version} when saving handoff.\n` +
397
439
  `_occ_instruction: When saving handoff state, you MUST pass expected_version: ${version} to prevent state collisions with other sessions.`
398
440
  : "";
441
+ // ─── Agent Identity Block (mirrors session_load_context output) ───
442
+ const ROLE_ICONS = {
443
+ dev: "🛠️", qa: "🔍", pm: "📋", lead: "🏗️",
444
+ security: "🔒", ux: "🎨", global: "🌐", cmo: "📢",
445
+ };
446
+ const agentName = getSettingSync("agent_name", "");
447
+ const defaultRole = getSettingSync("default_role", "");
448
+ let identityBlock = "";
449
+ if (agentName || (defaultRole && defaultRole !== "global")) {
450
+ const icon = ROLE_ICONS[defaultRole] || "🤖";
451
+ const namePart = agentName ? `👋 **${agentName}**` : `👋 **Agent**`;
452
+ const rolePart = defaultRole ? ` · Role: \`${defaultRole}\`` : "";
453
+ identityBlock = `\n\n[👤 AGENT IDENTITY]\n${icon} ${namePart}${rolePart}`;
454
+ }
399
455
  return {
400
456
  contents: [{
401
457
  uri,
402
458
  mimeType: "text/plain",
403
- text: `📋 Session context for "${project}" (standard):\n\n${formattedContext.trim()}${versionNote}`,
459
+ text: `📋 Session context for "${project}" (standard):\n\n${formattedContext.trim()}${identityBlock}${versionNote}`,
404
460
  }],
405
461
  };
406
462
  }
@@ -525,6 +581,11 @@ export function createServer() {
525
581
  if (!SESSION_MEMORY_ENABLED)
526
582
  throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
527
583
  return await sessionForgetMemoryHandler(args);
584
+ // ─── v3.1: TTL Retention Tool ───
585
+ case "knowledge_set_retention":
586
+ if (!SESSION_MEMORY_ENABLED)
587
+ throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
588
+ return await knowledgeSetRetentionHandler(args);
528
589
  // ─── v3.0: Agent Hivemind Tools ───
529
590
  case "agent_register":
530
591
  if (!SESSION_MEMORY_ENABLED)
@@ -633,37 +694,70 @@ export function createSandboxServer() {
633
694
  * responses to stdout. Log messages go to stderr.
634
695
  */
635
696
  export async function startServer() {
697
+ // Pre-warm the config settings cache BEFORE connecting the MCP transport.
698
+ // This ensures getSettingSync() returns real values (agent_name, default_role)
699
+ // during the Initialize handshake — zero extra latency for resource reads.
700
+ // initConfigStorage() is local SQLite only (~5ms), safe to await.
701
+ await initConfigStorage();
636
702
  const server = createServer();
637
703
  const transport = new StdioServerTransport();
638
704
  await server.connect(transport);
639
- // ─── v2.0 Step 6: Initialize SyncBus (Telepathy) ───
705
+ // Pre-warm storage AFTER connecting fired async so we never block the
706
+ // stdio handshake. Supabase REST initialization can take 500ms–5s; blocking
707
+ // on it before server.connect() was the root cause of the 1m 56s CLI delay.
708
+ // By the time the first real tool/resource call arrives, the singleton is warm.
640
709
  if (SESSION_MEMORY_ENABLED) {
641
- try {
642
- const syncBus = await getSyncBus();
643
- await syncBus.startListening();
644
- syncBus.on("update", (event) => {
645
- // Send an MCP logging notification to the IDE
646
- try {
647
- server.sendLoggingMessage({
648
- level: "info",
649
- data: `[Prism Telepathy] \u{1F9E0} Another agent just updated the memory for ` +
650
- `'${event.project}' to version ${event.version}. ` +
651
- `You may want to run session_load_context to sync up.`,
652
- });
653
- }
654
- catch (err) {
655
- console.error(`[Telepathy] Failed to send notification: ${err}`);
710
+ const STORAGE_TIMEOUT_MS = 10_000;
711
+ storageReady = Promise.race([
712
+ getStorage().then(() => { storageIsReady = true; }),
713
+ new Promise(resolve => setTimeout(() => {
714
+ if (!storageIsReady) {
715
+ console.error(`[Prism] Storage pre-warm timed out after ${STORAGE_TIMEOUT_MS}ms (non-fatal)`);
656
716
  }
657
- });
658
- }
659
- catch (err) {
660
- console.error(`[Telepathy] SyncBus init failed (non-fatal): ${err}`);
661
- }
717
+ resolve();
718
+ }, STORAGE_TIMEOUT_MS)),
719
+ ]).catch(err => {
720
+ console.error(`[Prism] Storage pre-warm failed (non-fatal): ${err}`);
721
+ });
722
+ }
723
+ // ─── v2.0 Step 6: Initialize SyncBus (Telepathy) ───
724
+ // Fire-and-forget — SyncBus is non-critical for startup.
725
+ // Awaiting getSyncBus() + startListening() could block the event loop
726
+ // if Supabase Realtime is slow, delaying MCP request processing.
727
+ if (SESSION_MEMORY_ENABLED) {
728
+ (async () => {
729
+ try {
730
+ const syncBus = await getSyncBus();
731
+ await syncBus.startListening();
732
+ syncBus.on("update", (event) => {
733
+ // Send an MCP logging notification to the IDE
734
+ try {
735
+ server.sendLoggingMessage({
736
+ level: "info",
737
+ data: `[Prism Telepathy] \u{1F9E0} Another agent just updated the memory for ` +
738
+ `'${event.project}' to version ${event.version}. ` +
739
+ `You may want to run session_load_context to sync up.`,
740
+ });
741
+ }
742
+ catch (err) {
743
+ console.error(`[Telepathy] Failed to send notification: ${err}`);
744
+ }
745
+ });
746
+ }
747
+ catch (err) {
748
+ console.error(`[Telepathy] SyncBus init failed (non-fatal): ${err}`);
749
+ }
750
+ })();
662
751
  }
663
752
  // ─── v2.0 Step 8: Mind Palace Dashboard ───
664
- startDashboardServer().catch(err => {
665
- console.error(`[Dashboard] Mind Palace startup failed (non-fatal): ${err}`);
666
- });
753
+ // Deferred to next tick — yields the event loop so the MCP stdio
754
+ // transport processes the initialize handshake before dashboard
755
+ // init spawns child processes (lsof) and awaits storage.
756
+ setTimeout(() => {
757
+ startDashboardServer().catch(err => {
758
+ console.error(`[Dashboard] Mind Palace startup failed (non-fatal): ${err}`);
759
+ });
760
+ }, 0);
667
761
  // Keep the process alive — without this, Node.js would exit
668
762
  // because there are no active event loop handles after the
669
763
  // synchronous setup completes.
@@ -16,6 +16,12 @@ import { homedir } from "os";
16
16
  const CONFIG_PATH = resolve(homedir(), ".prism-mcp", "prism-config.db");
17
17
  let configClient = null;
18
18
  let initialized = false;
19
+ // ─── In-memory settings cache ──────────────────────────────────────
20
+ // Preloaded during initConfigStorage() so that hot-path MCP handlers
21
+ // (e.g. ReadResourceRequestSchema) can read settings synchronously
22
+ // without opening an additional SQLite round-trip and stalling the
23
+ // MCP stdio handshake (which causes a black-screen on startup).
24
+ let settingsCache = null;
19
25
  function getClient() {
20
26
  if (!configClient) {
21
27
  configClient = createClient({
@@ -35,17 +41,43 @@ export async function initConfigStorage() {
35
41
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36
42
  )
37
43
  `);
44
+ // Preload all rows into the cache so subsequent reads are zero-cost.
45
+ const rs = await client.execute("SELECT key, value FROM system_settings");
46
+ settingsCache = {};
47
+ for (const row of rs.rows) {
48
+ settingsCache[row.key] = row.value;
49
+ }
38
50
  initialized = true;
39
51
  }
52
+ /**
53
+ * Synchronous setting read — served from the in-memory cache.
54
+ * Returns defaultValue if the cache hasn't been populated yet (e.g. very
55
+ * early startup before initConfigStorage() has been called) or if the key
56
+ * doesn't exist. Safe to call from any MCP request handler without triggering
57
+ * a SQLite round-trip.
58
+ */
59
+ export function getSettingSync(key, defaultValue = "") {
60
+ if (!settingsCache)
61
+ return defaultValue;
62
+ return settingsCache[key] ?? defaultValue;
63
+ }
40
64
  export async function getSetting(key, defaultValue = "") {
41
65
  await initConfigStorage();
66
+ // Serve from cache when warm (the common case after startup).
67
+ if (settingsCache && key in settingsCache) {
68
+ return settingsCache[key];
69
+ }
42
70
  const client = getClient();
43
71
  const rs = await client.execute({
44
72
  sql: "SELECT value FROM system_settings WHERE key = ?",
45
73
  args: [key],
46
74
  });
47
75
  if (rs.rows.length > 0) {
48
- return rs.rows[0].value;
76
+ const value = rs.rows[0].value;
77
+ // Populate cache entry for future reads.
78
+ if (settingsCache)
79
+ settingsCache[key] = value;
80
+ return value;
49
81
  }
50
82
  return defaultValue;
51
83
  }
@@ -60,9 +92,17 @@ export async function setSetting(key, value) {
60
92
  `,
61
93
  args: [key, value],
62
94
  });
95
+ // Keep the cache in sync so getSettingSync() reflects the new value immediately.
96
+ if (settingsCache) {
97
+ settingsCache[key] = value;
98
+ }
63
99
  }
64
100
  export async function getAllSettings() {
65
101
  await initConfigStorage();
102
+ // Return a snapshot of the cache (avoids a redundant DB round-trip).
103
+ if (settingsCache) {
104
+ return { ...settingsCache };
105
+ }
66
106
  const client = getClient();
67
107
  const rs = await client.execute("SELECT key, value FROM system_settings");
68
108
  const settings = {};
@@ -26,13 +26,35 @@ export class SqliteStorage {
26
26
  db;
27
27
  dbPath;
28
28
  // ─── Lifecycle ─────────────────────────────────────────────
29
- async initialize() {
30
- // Resolve ~/.prism-mcp/ directory
31
- const prismDir = path.join(os.homedir(), ".prism-mcp");
32
- if (!fs.existsSync(prismDir)) {
33
- fs.mkdirSync(prismDir, { recursive: true });
29
+ async initialize(dbPath) {
30
+ // ─── DB Path Resolution ────────────────────────────────────────────
31
+ // Priority:
32
+ // 1. Explicit dbPath argument — used by tests to inject a per-instance
33
+ // path with ZERO global side-effects. No env mutation, no race risk,
34
+ // safe under full parallel test execution.
35
+ // 2. Default: ~/.prism-mcp/data.db — used by production server startup.
36
+ //
37
+ // Why explict arg over env var?
38
+ // Env vars are process-global. Mutating them around an async boundary
39
+ // (set → await initialize() → restore) is racey when multiple test
40
+ // suites run in parallel: suite B can clobber PRISM_DB_PATH between
41
+ // suite A's write and suite A's read. A direct argument has no
42
+ // observable global state and cannot race.
43
+ let resolvedPath;
44
+ if (dbPath) {
45
+ resolvedPath = dbPath;
46
+ const dir = path.dirname(resolvedPath);
47
+ if (!fs.existsSync(dir))
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ }
50
+ else {
51
+ const prismDir = path.join(os.homedir(), ".prism-mcp");
52
+ if (!fs.existsSync(prismDir)) {
53
+ fs.mkdirSync(prismDir, { recursive: true });
54
+ }
55
+ resolvedPath = path.join(prismDir, "data.db");
34
56
  }
35
- this.dbPath = path.join(prismDir, "data.db");
57
+ this.dbPath = resolvedPath;
36
58
  this.db = createClient({
37
59
  url: `file:${this.dbPath}`,
38
60
  });
@@ -261,7 +283,19 @@ export class SqliteStorage {
261
283
  )
262
284
  `);
263
285
  await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_registry_project ON agent_registry(project, user_id)`);
264
- // system_settings: key-value store for dashboard runtime settings (v3.0)
286
+ // ── Note: system_settings is intentionally orphaned ─────────────────
287
+ // This table is created for forward-compatibility but is NOT the active
288
+ // settings store. Both SqliteStorage and SupabaseStorage proxy settings
289
+ // calls to configStorage.js (JSON file on disk) via:
290
+ // import { getSetting, setSetting, getAllSettings } from "./configStorage.js"
291
+ //
292
+ // The table is NOT safe to drop from the migration because existing user
293
+ // deployments may already have it and SQLite has no IF EXISTS for DROP
294
+ // in a safe cross-version migration. Instead, a future release can
295
+ // repoint getSettings/setSetting to use this.db.execute() here and
296
+ // retire configStorage.js at that point.
297
+ //
298
+ // See: src/storage/interface.ts "v3.0: Dashboard Settings (configStorage proxy)"
265
299
  await this.db.execute(`
266
300
  CREATE TABLE IF NOT EXISTS system_settings (
267
301
  key TEXT PRIMARY KEY,
@@ -292,6 +326,15 @@ export class SqliteStorage {
292
326
  // e.g., "created_at.desc" → "created_at DESC"
293
327
  const parts = value.split(".");
294
328
  const col = parts[0];
329
+ // ── SQL Injection Guard ──────────────────────────────────────────
330
+ // col is interpolated directly into the ORDER BY clause. We must
331
+ // reject anything that isn't a plain identifier (letters, digits,
332
+ // underscores) before it touches the query string.
333
+ // Note: @libsql/client already blocks stacked queries (;DROP TABLE),
334
+ // but CASE WHEN / expression injection is still possible without this.
335
+ if (!/^[a-zA-Z0-9_]+$/.test(col)) {
336
+ throw new Error(`Invalid order column: "${col}". Only alphanumeric identifiers are allowed.`);
337
+ }
295
338
  const dir = parts[1]?.toUpperCase() === "DESC" ? "DESC" : "ASC";
296
339
  order = `ORDER BY ${col} ${dir}`;
297
340
  continue;
@@ -357,7 +400,13 @@ export class SqliteStorage {
357
400
  return [];
358
401
  if (typeof value === "string") {
359
402
  try {
360
- return JSON.parse(value);
403
+ const parsed = JSON.parse(value);
404
+ // ── Type Safety Guard ────────────────────────────────────────────
405
+ // JSON.parse() can return any type (object, number, boolean, null).
406
+ // If a malformed non-array JSON string (e.g. "{}") somehow made it
407
+ // into the DB, callers would crash on .map()/.filter().
408
+ // Force it to an array so all downstream code stays safe.
409
+ return Array.isArray(parsed) ? parsed : [];
361
410
  }
362
411
  catch {
363
412
  return [];
@@ -1142,4 +1191,112 @@ export class SqliteStorage {
1142
1191
  async getAllSettings() {
1143
1192
  return cfgGetAll();
1144
1193
  }
1194
+ // ─── v3.1: Memory Analytics ──────────────────────────────────────────────
1195
+ //
1196
+ // Returns usage statistics for the Mind Palace dashboard.
1197
+ // Two SQL queries are used:
1198
+ // Query 1 — aggregate counts (fast, single pass)
1199
+ // Query 2 — sessions-per-day for the 14-day sparkline
1200
+ //
1201
+ // Both queries exclude:
1202
+ // • archived_at IS NOT NULL — TTL-expired entries (soft-deleted by expireByTTL)
1203
+ // • deleted_at IS NOT NULL — GDPR tombstones (from session_forget_memory)
1204
+ // This ensures the dashboard only shows "live" memory, matching what the
1205
+ // LLM actually sees during session_load_context.
1206
+ async getAnalytics(project, userId) {
1207
+ // Query 1: Aggregate stats — total entries, rollup count, tokens saved,
1208
+ // and average summary length (used as a proxy for knowledge richness).
1209
+ const countResult = await this.db.execute({
1210
+ sql: `SELECT
1211
+ COUNT(*) AS total_entries,
1212
+ SUM(CASE WHEN is_rollup = 1 THEN 1 ELSE 0 END) AS total_rollups,
1213
+ -- rollup_count tracks how many raw entries each rollup replaced,
1214
+ -- so we can show "X entries saved by compaction" in the dashboard.
1215
+ SUM(CASE WHEN is_rollup = 1 THEN COALESCE(rollup_count, 0) ELSE 0 END) AS rollup_savings,
1216
+ COALESCE(AVG(LENGTH(summary)), 0) AS avg_summary_length
1217
+ FROM session_ledger
1218
+ WHERE project = ? AND user_id = ?
1219
+ AND archived_at IS NULL AND deleted_at IS NULL`,
1220
+ args: [project, userId],
1221
+ });
1222
+ const row = countResult.rows[0];
1223
+ // Query 2: Sessions per day for the sparkline (last 14 days).
1224
+ // We only count non-rollup entries so the chart reflects actual work sessions,
1225
+ // not compaction operations.
1226
+ const sparkResult = await this.db.execute({
1227
+ sql: `SELECT
1228
+ DATE(created_at) AS date,
1229
+ COUNT(*) AS count
1230
+ FROM session_ledger
1231
+ WHERE project = ? AND user_id = ?
1232
+ AND archived_at IS NULL AND deleted_at IS NULL
1233
+ AND is_rollup = 0
1234
+ AND created_at >= DATE('now', '-14 days')
1235
+ GROUP BY DATE(created_at)
1236
+ ORDER BY date ASC`,
1237
+ args: [project, userId],
1238
+ });
1239
+ // Fill in zeros for days with no sessions so the sparkline always
1240
+ // has exactly 14 bars regardless of how sparse the data is.
1241
+ // A gap-fill approach (day loop + Map lookup) is much simpler than
1242
+ // a SQL recursive CTE for this use case.
1243
+ const sparkMap = new Map();
1244
+ for (const r of sparkResult.rows) {
1245
+ sparkMap.set(r.date, r.count);
1246
+ }
1247
+ const sessionsByDay = [];
1248
+ for (let i = 13; i >= 0; i--) {
1249
+ const date = new Date();
1250
+ date.setDate(date.getDate() - i);
1251
+ const dateStr = date.toISOString().slice(0, 10);
1252
+ sessionsByDay.push({ date: dateStr, count: sparkMap.get(dateStr) || 0 });
1253
+ }
1254
+ return {
1255
+ totalEntries: row?.total_entries || 0,
1256
+ totalRollups: row?.total_rollups || 0,
1257
+ rollupSavings: row?.rollup_savings || 0,
1258
+ avgSummaryLength: Math.round(row?.avg_summary_length || 0),
1259
+ sessionsByDay,
1260
+ };
1261
+ }
1262
+ // ─── v3.1: TTL / Automated Data Retention ────────────────────────────────
1263
+ //
1264
+ // Design: SOFT-DELETE (not hard-delete)
1265
+ //
1266
+ // We set archived_at rather than deleting rows. This means:
1267
+ // • The entry disappears from session_load_context and knowledge_search
1268
+ // immediately (both queries filter on archived_at IS NULL)
1269
+ // • The row is preserved for audit trails and GDPR right-of-access requests
1270
+ // • A hard-delete can still be performed later via session_forget_memory
1271
+ // with hard_delete: true
1272
+ //
1273
+ // Rollup entries (is_rollup = 1) are intentionally excluded from expiry:
1274
+ // • Rollups are dense summaries of many sessions — losing them would wipe
1275
+ // the entire compacted history, not just old raw entries.
1276
+ // • If users want to clean up rollups, they should use knowledge_forget
1277
+ // or session_forget_memory explicitly.
1278
+ //
1279
+ // The minimum TTL enforced in the handler is 7 days, so the cutoff is
1280
+ // always at least one week in the past. This prevents accidental mass-delete.
1281
+ async expireByTTL(project, ttlDays, userId) {
1282
+ const cutoff = new Date();
1283
+ cutoff.setDate(cutoff.getDate() - ttlDays);
1284
+ const cutoffStr = cutoff.toISOString();
1285
+ // Use archived_at (soft-delete) rather than hard-deleting rows.
1286
+ // This preserves the audit trail while hiding old entries from all
1287
+ // standard queries that filter on `archived_at IS NULL`.
1288
+ const result = await this.db.execute({
1289
+ sql: `UPDATE session_ledger
1290
+ SET archived_at = datetime('now')
1291
+ WHERE project = ? AND user_id = ?
1292
+ AND is_rollup = 0 -- never expire compacted rollups
1293
+ AND archived_at IS NULL -- idempotent: skip already-expired rows
1294
+ AND deleted_at IS NULL -- skip GDPR tombstones
1295
+ AND created_at < ?`,
1296
+ args: [project, userId, cutoffStr],
1297
+ });
1298
+ const expired = result.rowsAffected || 0;
1299
+ debugLog(`[SqliteStorage] TTL sweep: expired ${expired} entries for "${project}" (cutoff: ${cutoffStr})`);
1300
+ return { expired };
1301
+ }
1145
1302
  }
@@ -296,4 +296,56 @@ export class SupabaseStorage {
296
296
  async getAllSettings() {
297
297
  return cfgGetAll();
298
298
  }
299
+ // ─── v3.1: Memory Analytics ──────────────────────────────────
300
+ async getAnalytics(project, userId) {
301
+ // Attempt to call a Supabase RPC. Falls back to zeroed struct if the RPC
302
+ // doesn't exist yet (avoids breaking users who haven't run the migration).
303
+ try {
304
+ const result = await supabaseRpc("get_project_analytics", {
305
+ p_project: project,
306
+ p_user_id: userId,
307
+ });
308
+ const data = Array.isArray(result) ? result[0] : result;
309
+ if (data) {
310
+ return {
311
+ totalEntries: data.total_entries || 0,
312
+ totalRollups: data.total_rollups || 0,
313
+ rollupSavings: data.rollup_savings || 0,
314
+ avgSummaryLength: data.avg_summary_length || 0,
315
+ sessionsByDay: data.sessions_by_day || [],
316
+ };
317
+ }
318
+ }
319
+ catch {
320
+ debugLog("[SupabaseStorage] getAnalytics RPC unavailable — returning zeroed struct");
321
+ }
322
+ // Graceful degradation: return zeroed struct so dashboard doesn't crash
323
+ return {
324
+ totalEntries: 0, totalRollups: 0, rollupSavings: 0,
325
+ avgSummaryLength: 0, sessionsByDay: [],
326
+ };
327
+ }
328
+ // ─── v3.1: TTL / Automated Data Retention ────────────────────
329
+ async expireByTTL(project, ttlDays, userId) {
330
+ const cutoff = new Date();
331
+ cutoff.setDate(cutoff.getDate() - ttlDays);
332
+ const cutoffStr = cutoff.toISOString();
333
+ // Use existing supabasePatch with PostgREST filter syntax
334
+ // No new RPC needed — PATCH with filter works for bulk soft-delete
335
+ try {
336
+ await supabasePatch("session_ledger", { archived_at: cutoffStr }, {
337
+ project: `eq.${project}`,
338
+ user_id: `eq.${userId}`,
339
+ "created_at": `lt.${cutoffStr}`,
340
+ "is_rollup": "eq.false",
341
+ "archived_at": "is.null",
342
+ });
343
+ }
344
+ catch (e) {
345
+ debugLog("[SupabaseStorage] expireByTTL failed: " + (e instanceof Error ? e.message : String(e)));
346
+ }
347
+ // Supabase PATCH doesn't return rowsAffected — return 0 (UI doesn't need exact count)
348
+ debugLog(`[SupabaseStorage] TTL sweep completed for "${project}" (cutoff: ${cutoffStr})`);
349
+ return { expired: 0 };
350
+ }
299
351
  }