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/README.md +21 -2
- package/dist/dashboard/server.js +247 -13
- package/dist/dashboard/ui.js +211 -1
- package/dist/server.js +123 -29
- package/dist/storage/configStorage.js +41 -1
- package/dist/storage/sqlite.js +165 -8
- package/dist/storage/supabase.js +52 -0
- package/dist/tools/agentRegistryHandlers.js +67 -24
- package/dist/tools/index.js +2 -2
- package/dist/tools/sessionMemoryDefinitions.js +35 -0
- package/dist/tools/sessionMemoryHandlers.js +86 -0
- package/package.json +2 -1
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
660
|
-
console.error(`[
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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 = {};
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -26,13 +26,35 @@ export class SqliteStorage {
|
|
|
26
26
|
db;
|
|
27
27
|
dbPath;
|
|
28
28
|
// ─── Lifecycle ─────────────────────────────────────────────
|
|
29
|
-
async initialize() {
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/storage/supabase.js
CHANGED
|
@@ -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
|
}
|