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.
@@ -8,6 +8,55 @@ import { getStorage } from "../storage/index.js";
8
8
  import { PRISM_USER_ID } from "../config.js";
9
9
  import { getRoleIcon } from "./agentRegistryDefinitions.js";
10
10
  import { getSetting } from "../storage/configStorage.js";
11
+ // ─── Helpers ─────────────────────────────────────────────────
12
+ /**
13
+ * Escape markdown metacharacters in user-controlled strings.
14
+ *
15
+ * Fields like `current_task`, `agent_name`, and `status` are user-provided
16
+ * and may contain characters (* _ ` [ etc.) that would corrupt the markdown
17
+ * formatting of agent_list_team output. We escape the most common ones to
18
+ * keep the display predictable without being overly aggressive.
19
+ *
20
+ * This is a display-integrity fix, not a security fix — MCP response text
21
+ * is rendered in the LLM's context where injection risk is low, but broken
22
+ * formatting reduces readability and can misguide the model.
23
+ */
24
+ function escapeMd(str) {
25
+ if (!str)
26
+ return "";
27
+ return str
28
+ .replace(/\\/g, "\\\\")
29
+ .replace(/\*/g, "\\*")
30
+ .replace(/_/g, "\\_")
31
+ .replace(/`/g, "\\`")
32
+ .replace(/\[/g, "\\[")
33
+ .replace(/\]/g, "\\]")
34
+ // HTML angle-bracket escaping: prevents raw tags (e.g. <script> in agent
35
+ // names) from bleeding into LLM context as markup.
36
+ .replace(/</g, "&lt;")
37
+ .replace(/>/g, "&gt;");
38
+ }
39
+ /**
40
+ * Returns a human-readable "time ago" string for a heartbeat timestamp.
41
+ *
42
+ * Clamped to prevent negative or NaN outputs due to:
43
+ * - Clock skew between agents (heartbeat appears in the future)
44
+ * - Malformed ISO strings that produce NaN from Date.parse()
45
+ * Fallback returns "just now" to avoid exposing confusing negative values.
46
+ */
47
+ function getTimeAgo(isoString) {
48
+ const parsed = new Date(isoString).getTime();
49
+ if (isNaN(parsed))
50
+ return "unknown time"; // malformed timestamp
51
+ const diffMs = Date.now() - parsed;
52
+ const mins = Math.max(0, Math.floor(diffMs / 60000)); // clamp at 0 (no "future")
53
+ if (mins < 1)
54
+ return "just now";
55
+ if (mins < 60)
56
+ return `${mins}m ago`;
57
+ const hours = Math.floor(mins / 60);
58
+ return `${hours}h ago`;
59
+ }
11
60
  // ─── Type Guards ─────────────────────────────────────────────
12
61
  function isAgentRegisterArgs(args) {
13
62
  return typeof args.project === "string";
@@ -30,7 +79,10 @@ export async function agentRegisterHandler(args) {
30
79
  const effectiveRole = args.role || await getSetting("default_role", "global");
31
80
  const effectiveName = args.agent_name || await getSetting("agent_name", "") || null;
32
81
  const storage = await getStorage();
33
- const result = await storage.registerAgent({
82
+ // Register the agent. We don't use the return value here — the storage
83
+ // layer handles upsert internally and there are no caller-visible fields
84
+ // (e.g. server-assigned IDs) that we need to surface in the response.
85
+ await storage.registerAgent({
34
86
  project: args.project,
35
87
  user_id: PRISM_USER_ID,
36
88
  role: effectiveRole,
@@ -43,10 +95,10 @@ export async function agentRegisterHandler(args) {
43
95
  content: [{
44
96
  type: "text",
45
97
  text: `${icon} **Agent Registered**\n\n` +
46
- `- **Project:** ${args.project}\n` +
47
- `- **Role:** ${effectiveRole}\n` +
48
- (effectiveName ? `- **Name:** ${effectiveName}\n` : "") +
49
- (args.current_task ? `- **Task:** ${args.current_task}\n` : "") +
98
+ `- **Project:** ${escapeMd(args.project)}\n` +
99
+ `- **Role:** ${escapeMd(effectiveRole)}\n` +
100
+ (effectiveName ? `- **Name:** ${escapeMd(effectiveName)}\n` : "") +
101
+ (args.current_task ? `- **Task:** ${escapeMd(args.current_task)}\n` : "") +
50
102
  `\nOther agents will see you when they call \`agent_list_team\` or \`session_load_context\`.`,
51
103
  }],
52
104
  };
@@ -64,8 +116,8 @@ export async function agentHeartbeatHandler(args) {
64
116
  return {
65
117
  content: [{
66
118
  type: "text",
67
- text: `💓 Heartbeat updated for **${effectiveRole}** on \`${args.project}\`.` +
68
- (args.current_task ? ` Task: ${args.current_task}` : ""),
119
+ text: `💓 Heartbeat updated for **${escapeMd(effectiveRole)}** on \`${escapeMd(args.project)}\`.` +
120
+ (args.current_task ? ` Task: ${escapeMd(args.current_task)}` : ""),
69
121
  }],
70
122
  };
71
123
  }
@@ -82,7 +134,7 @@ export async function agentListTeamHandler(args) {
82
134
  return {
83
135
  content: [{
84
136
  type: "text",
85
- text: `No active agents on \`${args.project}\`. Use \`agent_register\` to join the team.`,
137
+ text: `No active agents on \`${escapeMd(args.project)}\`. Use \`agent_register\` to join the team.`,
86
138
  }],
87
139
  };
88
140
  }
@@ -91,29 +143,20 @@ export async function agentListTeamHandler(args) {
91
143
  const ago = agent.last_heartbeat
92
144
  ? getTimeAgo(agent.last_heartbeat)
93
145
  : "unknown";
94
- return (`${icon} **${agent.role}**` +
95
- (agent.agent_name ? ` (${agent.agent_name})` : "") +
96
- ` ${agent.status}` +
97
- (agent.current_task ? ` | Task: ${agent.current_task}` : "") +
146
+ // escapeMd() applied to all user-controlled fields to prevent
147
+ // markdown metacharacters in task descriptions from corrupting output
148
+ return (`${icon} **${escapeMd(agent.role)}**` +
149
+ (agent.agent_name ? ` (${escapeMd(agent.agent_name)})` : "") +
150
+ ` — ${escapeMd(agent.status)}` +
151
+ (agent.current_task ? ` | Task: ${escapeMd(agent.current_task)}` : "") +
98
152
  ` | Last seen: ${ago}`);
99
153
  });
100
154
  return {
101
155
  content: [{
102
156
  type: "text",
103
- text: `## 🐝 Active Hivemind Team — \`${args.project}\`\n\n` +
157
+ text: `## 🐝 Active Hivemind Team — \`${escapeMd(args.project)}\`\n\n` +
104
158
  lines.join("\n") +
105
159
  `\n\n_${team.length} agent(s) active. Stale agents (>30min) auto-pruned._`,
106
160
  }],
107
161
  };
108
162
  }
109
- // ─── Helpers ─────────────────────────────────────────────────
110
- function getTimeAgo(isoString) {
111
- const diff = Date.now() - new Date(isoString).getTime();
112
- const mins = Math.floor(diff / 60000);
113
- if (mins < 1)
114
- return "just now";
115
- if (mins < 60)
116
- return `${mins}m ago`;
117
- const hours = Math.floor(mins / 60);
118
- return `${hours}h ago`;
119
- }
@@ -26,8 +26,8 @@ export { webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, br
26
26
  // This file always exports them — server.ts decides whether to include them in the tool list.
27
27
  //
28
28
  // v0.4.0: Added SESSION_COMPACT_LEDGER_TOOL and SESSION_SEARCH_MEMORY_TOOL
29
- export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_FORGET_MEMORY_TOOL } from "./sessionMemoryDefinitions.js";
30
- export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler } from "./sessionMemoryHandlers.js";
29
+ export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_FORGET_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL } from "./sessionMemoryDefinitions.js";
30
+ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler, knowledgeSetRetentionHandler } from "./sessionMemoryHandlers.js";
31
31
  // ── Compaction Handler (v0.4.0 — Enhancement #2) ──
32
32
  // The compaction handler is in a separate file because it's significantly
33
33
  // more complex than the other session memory handlers (chunked Gemini
@@ -600,3 +600,38 @@ export function isSessionForgetMemoryArgs(args) {
600
600
  "memory_id" in args &&
601
601
  typeof args.memory_id === "string");
602
602
  }
603
+ // ─── v3.1: Knowledge Set Retention (TTL) ─────────────────────
604
+ export const KNOWLEDGE_SET_RETENTION_TOOL = {
605
+ name: "knowledge_set_retention",
606
+ description: "Set an automatic data retention policy (TTL) for a project's memory. " +
607
+ "Entries older than ttl_days will be soft-deleted (archived) automatically " +
608
+ "on every server startup and every 12 hours while running.\n\n" +
609
+ "**Use cases:**\n" +
610
+ "- Set `ttl_days: 90` to auto-expire sessions older than 3 months\n" +
611
+ "- Set `ttl_days: 0` to disable auto-expiry (default)\n\n" +
612
+ "**Note:** Rollup/compaction entries are never expired — only raw sessions.",
613
+ inputSchema: {
614
+ type: "object",
615
+ properties: {
616
+ project: {
617
+ type: "string",
618
+ description: "Project to set retention policy for.",
619
+ },
620
+ ttl_days: {
621
+ type: "integer",
622
+ description: "Entries older than this many days are auto-expired. " +
623
+ "Set to 0 to disable. Minimum: 7 days when enabled.",
624
+ minimum: 0,
625
+ },
626
+ },
627
+ required: ["project", "ttl_days"],
628
+ },
629
+ };
630
+ export function isKnowledgeSetRetentionArgs(args) {
631
+ return (typeof args === "object" &&
632
+ args !== null &&
633
+ "project" in args &&
634
+ typeof args.project === "string" &&
635
+ "ttl_days" in args &&
636
+ typeof args.ttl_days === "number");
637
+ }
@@ -32,7 +32,12 @@ import { GOOGLE_API_KEY, PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS
32
32
  import { captureLocalEnvironment } from "../utils/autoCapture.js";
33
33
  import { isSessionSaveLedgerArgs, isSessionSaveHandoffArgs, isSessionLoadContextArgs, isKnowledgeSearchArgs, isKnowledgeForgetArgs, isSessionSearchMemoryArgs, isBackfillEmbeddingsArgs, isMemoryHistoryArgs, isMemoryCheckoutArgs, isSessionHealthCheckArgs, // v2.2.0: health check type guard
34
34
  isSessionForgetMemoryArgs, // Phase 2: GDPR-compliant memory deletion type guard
35
+ isKnowledgeSetRetentionArgs, // v3.1: TTL retention policy type guard
35
36
  } from "./sessionMemoryDefinitions.js";
37
+ // v3.1: In-memory debounce lock for auto-compaction.
38
+ // Prevents multiple concurrent Gemini compaction tasks for the same project
39
+ // when many agents call session_save_ledger at the same time.
40
+ const activeCompactions = new Set();
36
41
  import { notifyResourceUpdate } from "../server.js";
37
42
  // ─── Save Ledger Handler ──────────────────────────────────────
38
43
  /**
@@ -85,6 +90,36 @@ export async function sessionSaveLedgerHandler(args) {
85
90
  });
86
91
  }
87
92
  }
93
+ // ─── Fire-and-forget auto-compact ────────────────────────────
94
+ // If the user has opted into auto-compact (via dashboard Settings → Boot),
95
+ // run a health check after saving and compact if brain is degraded/unhealthy.
96
+ // Uses debounce Set to prevent concurrent Gemini calls for same project.
97
+ getSetting("compaction_auto", "false").then(async (autoCompact) => {
98
+ if (autoCompact !== "true")
99
+ return;
100
+ if (activeCompactions.has(project)) {
101
+ debugLog(`[auto-compact] Skipped for "${project}" — compaction already in progress`);
102
+ return;
103
+ }
104
+ activeCompactions.add(project);
105
+ try {
106
+ const { runHealthCheck } = await import("../utils/healthCheck.js");
107
+ const { compactLedgerHandler } = await import("./compactionHandler.js");
108
+ const healthStats = await storage.getHealthStats(PRISM_USER_ID);
109
+ const report = runHealthCheck(healthStats);
110
+ if (report.status === "degraded" || report.status === "unhealthy") {
111
+ debugLog(`[auto-compact] Brain "${project}" is ${report.status} — triggering compaction`);
112
+ await compactLedgerHandler({ project });
113
+ debugLog(`[auto-compact] Compaction complete for "${project}"`);
114
+ }
115
+ }
116
+ catch (err) {
117
+ console.error(`[auto-compact] Non-fatal error for "${project}": ${err}`);
118
+ }
119
+ finally {
120
+ activeCompactions.delete(project);
121
+ }
122
+ }).catch(() => { });
88
123
  return {
89
124
  content: [{
90
125
  type: "text",
@@ -1467,3 +1502,54 @@ export async function sessionForgetMemoryHandler(args) {
1467
1502
  };
1468
1503
  }
1469
1504
  }
1505
+ // ─── v3.1: Knowledge Set Retention Handler ────────────────
1506
+ /**
1507
+ * Set a TTL (data retention policy) for a project.
1508
+ * Saves the policy to configStorage, then immediately runs one sweep
1509
+ * to expire any entries that are already over the TTL.
1510
+ */
1511
+ export async function knowledgeSetRetentionHandler(args) {
1512
+ if (!isKnowledgeSetRetentionArgs(args)) {
1513
+ throw new Error("Invalid arguments for knowledge_set_retention");
1514
+ }
1515
+ const { project, ttl_days } = args;
1516
+ if (ttl_days < 0) {
1517
+ return {
1518
+ content: [{ type: "text", text: "Error: ttl_days must be 0 (disabled) or a positive integer." }],
1519
+ isError: true,
1520
+ };
1521
+ }
1522
+ if (ttl_days > 0 && ttl_days < 7) {
1523
+ return {
1524
+ content: [{ type: "text", text: "Error: Minimum TTL is 7 days to prevent accidental data loss." }],
1525
+ isError: true,
1526
+ };
1527
+ }
1528
+ const storage = await getStorage();
1529
+ // Save policy to configStorage so server.ts sweep can read it
1530
+ await storage.setSetting(`ttl:${project}`, String(ttl_days));
1531
+ if (ttl_days === 0) {
1532
+ return {
1533
+ content: [{
1534
+ type: "text",
1535
+ text: `✅ Data retention **disabled** for project \"${project}\".\n\nEntries will be kept indefinitely.`,
1536
+ }],
1537
+ isError: false,
1538
+ };
1539
+ }
1540
+ // Run an immediate sweep for entries already past TTL
1541
+ const result = await storage.expireByTTL(project, ttl_days, PRISM_USER_ID);
1542
+ return {
1543
+ content: [{
1544
+ type: "text",
1545
+ text: `⏱️ **Retention policy set** for project \"${project}\":\n\n` +
1546
+ `- Auto-expire entries older than: **${ttl_days} days**\n` +
1547
+ `- Sweep runs on: server startup + every 12 hours\n` +
1548
+ `- Rollup/compaction entries: **never expired**\n\n` +
1549
+ (result.expired > 0
1550
+ ? `🗑️ Immediately expired **${result.expired}** entries already past the ${ttl_days}-day threshold.`
1551
+ : `✅ No existing entries exceeded the ${ttl_days}-day threshold.`),
1552
+ }],
1553
+ isError: false,
1554
+ };
1555
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "mcpName": "io.github.dcostenco/prism-mcp",
5
5
  "description": "The Mind Palace for AI Agents — local-first MCP server with persistent memory (SQLite/Supabase), visual dashboard, time travel, multi-agent sync, Morning Briefings, reality drift detection, code mode templates, semantic vector search, and Brave Search + Gemini analysis. Zero-config local mode.",
6
6
  "module": "index.ts",
@@ -87,6 +87,7 @@
87
87
  "@modelcontextprotocol/sdk": "^1.27.1",
88
88
  "@supabase/supabase-js": "^2.99.3",
89
89
  "dotenv": "^16.5.0",
90
+ "fflate": "^0.8.2",
90
91
  "quickjs-emscripten": "^0.32.0"
91
92
  }
92
93
  }