prism-mcp-server 2.5.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,30 +17,25 @@ import { debugLog } from "../utils/logger.js";
17
17
  export class SupabaseStorage {
18
18
  // ─── Lifecycle ─────────────────────────────────────────────
19
19
  async initialize() {
20
- // Supabase is always ready — connection is stateless (REST API).
21
- // The SUPABASE_URL and SUPABASE_KEY are validated at import time
22
- // by supabaseApi.ts's guard clause.
23
20
  debugLog("[SupabaseStorage] Initialized (REST API, stateless)");
24
21
  }
25
22
  async close() {
26
- // No-op for Supabase — connections are stateless.
27
23
  debugLog("[SupabaseStorage] Closed (no-op for REST)");
28
24
  }
29
25
  // ─── Ledger Operations ─────────────────────────────────────
30
26
  async saveLedger(entry) {
31
- // Direct mapping from sessionSaveLedgerHandler line 95
32
27
  const record = {
33
28
  project: entry.project,
34
29
  conversation_id: entry.conversation_id,
35
30
  summary: entry.summary,
36
31
  user_id: entry.user_id,
32
+ role: entry.role || "global", // v3.0: include role
37
33
  todos: entry.todos || [],
38
34
  files_changed: entry.files_changed || [],
39
35
  decisions: entry.decisions || [],
40
36
  keywords: entry.keywords || [],
41
37
  ...(entry.is_rollup !== undefined && { is_rollup: entry.is_rollup }),
42
38
  ...(entry.rollup_count !== undefined && { rollup_count: entry.rollup_count }),
43
- // Compaction handler also sets title, agent_name for rollup entries
44
39
  ...(entry.is_rollup && {
45
40
  title: `Session Rollup (${entry.rollup_count || 0} entries)`,
46
41
  agent_name: "prism-compactor",
@@ -49,63 +44,36 @@ export class SupabaseStorage {
49
44
  return supabasePost("session_ledger", record);
50
45
  }
51
46
  async patchLedger(id, data) {
52
- // Direct mapping from sessionSaveLedgerHandler line 115 (embedding patch)
53
- // and compactionHandler line 292 (archive patch)
54
47
  await supabasePatch("session_ledger", data, { id: `eq.${id}` });
55
48
  }
56
49
  async getLedgerEntries(params) {
57
- // Direct mapping from:
58
- // - compactionHandler line 143 (count entries for project)
59
- // - compactionHandler line 211 (fetch oldest entries)
60
- // - backfillEmbeddingsHandler line 700 (find missing embeddings)
61
- // - knowledgeForgetHandler line 479 (dry run count)
62
50
  const result = await supabaseGet("session_ledger", params);
63
51
  return Array.isArray(result) ? result : [];
64
52
  }
65
53
  async deleteLedger(params) {
66
- // Direct mapping from knowledgeForgetHandler line 482
67
54
  const result = await supabaseDelete("session_ledger", params);
68
55
  return Array.isArray(result) ? result : [];
69
56
  }
70
57
  // ─── Phase 2: GDPR-Compliant Memory Deletion ──────────────
71
- //
72
- // These methods are SURGICAL — they operate on a single entry by ID.
73
- // They MUST verify user_id ownership to prevent cross-user deletion.
74
- //
75
- // softDeleteLedger: Sets deleted_at + deleted_reason. Entry stays in
76
- // DB for audit trail. Supabase RPCs and TypeScript queries filter
77
- // it out via "WHERE deleted_at IS NULL". Reversible.
78
- //
79
- // hardDeleteLedger: Physical DELETE. Irreversible. For GDPR Article 17
80
- // "right to erasure" when the audit trail must also be removed.
81
58
  async softDeleteLedger(id, userId, reason) {
82
- // PATCH (not DELETE): sets tombstone fields while preserving the row.
83
- // The deleted_at timestamp is set server-side for consistency.
84
- // deleted_reason captures the GDPR justification (e.g., "User requested",
85
- // "Data retention policy", "GDPR Article 17 request").
86
59
  await supabasePatch("session_ledger", {
87
60
  deleted_at: new Date().toISOString(),
88
61
  deleted_reason: reason || null,
89
62
  }, {
90
63
  id: `eq.${id}`,
91
- user_id: `eq.${userId}`, // Ownership guard — prevents cross-user deletion
64
+ user_id: `eq.${userId}`,
92
65
  });
93
66
  debugLog(`[SupabaseStorage] Soft-deleted ledger entry ${id} (reason: ${reason || "none"})`);
94
67
  }
95
68
  async hardDeleteLedger(id, userId) {
96
- // Physical DELETE — row is permanently removed from the database.
97
- // This is irreversible. The FTS5 index (if any) is cleaned up by
98
- // Supabase's built-in trigger handling.
99
69
  await supabaseDelete("session_ledger", {
100
70
  id: `eq.${id}`,
101
- user_id: `eq.${userId}`, // Ownership guard
71
+ user_id: `eq.${userId}`,
102
72
  });
103
73
  debugLog(`[SupabaseStorage] Hard-deleted ledger entry ${id}`);
104
74
  }
105
75
  // ─── Handoff Operations ────────────────────────────────────
106
76
  async saveHandoff(handoff, expectedVersion) {
107
- // Direct mapping from sessionSaveHandoffHandler line 214
108
- // Calls the save_handoff_with_version RPC for OCC
109
77
  const result = await supabaseRpc("save_handoff_with_version", {
110
78
  p_project: handoff.project,
111
79
  p_expected_version: expectedVersion ?? null,
@@ -116,6 +84,7 @@ export class SupabaseStorage {
116
84
  p_key_context: handoff.key_context ?? null,
117
85
  p_active_branch: handoff.active_branch ?? null,
118
86
  p_user_id: handoff.user_id,
87
+ p_role: handoff.role || "global", // v3.0: pass role to RPC
119
88
  });
120
89
  const data = Array.isArray(result) ? result[0] : result;
121
90
  if (data?.status === "conflict") {
@@ -130,25 +99,24 @@ export class SupabaseStorage {
130
99
  };
131
100
  }
132
101
  async deleteHandoff(project, userId) {
133
- // Direct mapping from knowledgeForgetHandler line 486
134
102
  await supabaseDelete("session_handoffs", {
135
103
  project: `eq.${project}`,
136
104
  user_id: `eq.${userId}`,
137
105
  });
138
106
  }
139
- async loadContext(project, level, userId) {
140
- // Direct mapping from sessionLoadContextHandler line 330
107
+ async loadContext(project, level, userId, role // v3.0: optional role filter
108
+ ) {
141
109
  const result = await supabaseRpc("get_session_context", {
142
110
  p_project: project,
143
111
  p_level: level,
144
112
  p_user_id: userId,
113
+ p_role: role || "global", // v3.0: pass role to RPC
145
114
  });
146
115
  const data = Array.isArray(result) ? result[0] : result;
147
116
  return data ?? null;
148
117
  }
149
118
  // ─── Search Operations ─────────────────────────────────────
150
119
  async searchKnowledge(params) {
151
- // Direct mapping from knowledgeSearchHandler line 388
152
120
  const result = await supabaseRpc("search_knowledge", {
153
121
  p_project: params.project || null,
154
122
  p_keywords: params.keywords,
@@ -164,7 +132,6 @@ export class SupabaseStorage {
164
132
  return data;
165
133
  }
166
134
  async searchMemory(params) {
167
- // Direct mapping from sessionSearchMemoryHandler line 583
168
135
  const result = await supabaseRpc("semantic_search_ledger", {
169
136
  p_query_embedding: params.queryEmbedding,
170
137
  p_project: params.project || null,
@@ -176,7 +143,6 @@ export class SupabaseStorage {
176
143
  }
177
144
  // ─── Compaction ────────────────────────────────────────────
178
145
  async getCompactionCandidates(threshold, keepRecent, userId) {
179
- // Direct mapping from compactionHandler line 165
180
146
  const result = await supabaseRpc("get_compaction_candidates", {
181
147
  p_threshold: threshold,
182
148
  p_keep_recent: keepRecent,
@@ -210,100 +176,112 @@ export class SupabaseStorage {
210
176
  order: "project.asc",
211
177
  });
212
178
  const rows = Array.isArray(data) ? data : [];
213
- // Deduplicate on the client side since Supabase doesn't support DISTINCT via REST
214
179
  return [...new Set(rows.map((r) => r.project))];
215
180
  }
216
181
  // ─── v2.2.0 Health Check (fsck) ─────────────────────────────
217
- /**
218
- * Gather raw health statistics via Supabase REST API.
219
- *
220
- * Supabase REST (PostgREST) doesn't support complex JOINs,
221
- * so we fetch raw data and let healthCheck.ts do the analysis
222
- * in pure JS — same approach as SQLite for consistency.
223
- */
224
182
  async getHealthStats(userId) {
225
- // ── Check 1: Entries missing embeddings ────────────────────
226
- // Fetch active entries where embedding column is null.
227
- // PostgREST filter: archived_at=is.null AND embedding=is.null
228
183
  const missingData = await supabaseGet("session_ledger", {
229
- select: "id", // only need count
230
- user_id: `eq.${userId}`, // scope to this user
231
- archived_at: "is.null", // only active entries
232
- embedding: "is.null", // missing embedding
184
+ select: "id",
185
+ user_id: `eq.${userId}`,
186
+ archived_at: "is.null",
187
+ embedding: "is.null",
233
188
  });
234
- // Count the returned rows (PostgREST returns array)
235
189
  const missingEmbeddings = Array.isArray(missingData) ? missingData.length : 0;
236
- // ── Check 2: All active summaries for JS duplicate detection ─
237
- // Pull id + project + summary so healthCheck.ts can run
238
- // Jaccard similarity comparison in-memory.
239
190
  const summData = await supabaseGet("session_ledger", {
240
- select: "id,project,summary", // minimal columns needed
241
- user_id: `eq.${userId}`, // scope to this user
242
- archived_at: "is.null", // only active entries
191
+ select: "id,project,summary",
192
+ user_id: `eq.${userId}`,
193
+ archived_at: "is.null",
243
194
  });
244
- // Map to typed array for the health check engine
245
195
  const activeLedgerSummaries = (Array.isArray(summData) ? summData : []).map((r) => ({
246
- id: r.id, // unique entry ID
247
- project: r.project, // project name
248
- summary: r.summary, // text for dupe comparison
196
+ id: r.id,
197
+ project: r.project,
198
+ summary: r.summary,
249
199
  }));
250
- // ── Check 3: Find orphaned handoffs ──────────────────────────
251
- // Fetch all handoff projects, then all ledger projects.
252
- // Difference = orphaned handoffs (handoff but no ledger entries).
253
200
  const handoffData = await supabaseGet("session_handoffs", {
254
- select: "project", // only need project names
255
- user_id: `eq.${userId}`, // scope to this user
201
+ select: "project",
202
+ user_id: `eq.${userId}`,
256
203
  });
257
- const handoffProjects = new Set(// set for O(1) lookup
258
- (Array.isArray(handoffData) ? handoffData : [])
204
+ const handoffProjects = new Set((Array.isArray(handoffData) ? handoffData : [])
259
205
  .map((r) => r.project));
260
206
  const ledgerData = await supabaseGet("session_ledger", {
261
- select: "project", // only need project names
262
- user_id: `eq.${userId}`, // scope to this user
263
- archived_at: "is.null", // only active entries
207
+ select: "project",
208
+ user_id: `eq.${userId}`,
209
+ archived_at: "is.null",
264
210
  });
265
- const ledgerProjects = new Set(// projects that have entries
266
- (Array.isArray(ledgerData) ? ledgerData : [])
211
+ const ledgerProjects = new Set((Array.isArray(ledgerData) ? ledgerData : [])
267
212
  .map((r) => r.project));
268
- // Orphaned = in handoffs but NOT in ledger
269
213
  const orphanedHandoffs = [...handoffProjects]
270
- .filter(p => !ledgerProjects.has(p)) // keep only orphans
271
- .map(project => ({ project })); // wrap in object
272
- // ── Check 4: Count stale rollups ─────────────────────────────
273
- // PostgREST can't do self-joins. Fetch rollups and archived
274
- // entries separately, then compute in JS.
214
+ .filter(p => !ledgerProjects.has(p))
215
+ .map(project => ({ project }));
275
216
  const rollupData = await supabaseGet("session_ledger", {
276
- select: "id,project", // rollup ID and project
277
- user_id: `eq.${userId}`, // scope to this user
278
- is_rollup: "eq.true", // only rollup entries
279
- archived_at: "is.null", // still active
217
+ select: "id,project",
218
+ user_id: `eq.${userId}`,
219
+ is_rollup: "eq.true",
220
+ archived_at: "is.null",
280
221
  });
281
222
  const archivedData = await supabaseGet("session_ledger", {
282
- select: "project", // just need project names
283
- user_id: `eq.${userId}`, // scope to this user
284
- "archived_at": "not.is.null", // only archived entries
223
+ select: "project",
224
+ user_id: `eq.${userId}`,
225
+ "archived_at": "not.is.null",
285
226
  });
286
- // Build a set of projects that have archived entries
287
227
  const archivedProjects = new Set((Array.isArray(archivedData) ? archivedData : [])
288
228
  .map((r) => r.project));
289
- // Stale = rollup exists but project has no archived originals
290
229
  const rollups = Array.isArray(rollupData) ? rollupData : [];
291
- const staleRollups = rollups.filter((r) => !archivedProjects.has(r.project) // no originals
292
- ).length;
293
- // ── Totals ───────────────────────────────────────────────────
294
- // Reuse data already fetched above to avoid extra API calls
230
+ const staleRollups = rollups.filter((r) => !archivedProjects.has(r.project)).length;
295
231
  const totalActiveEntries = activeLedgerSummaries.length;
296
232
  const totalHandoffs = handoffProjects.size;
297
233
  const totalRollups = rollups.length;
298
- // ── Return raw health stats for the JS engine ────────────────
299
234
  return {
300
- missingEmbeddings, // entries needing embedding repair
301
- activeLedgerSummaries, // raw summaries for JS dupe detection
302
- orphanedHandoffs, // projects with handoff but no ledger
303
- staleRollups, // rollups with no archived originals
304
- totalActiveEntries, // grand total of active entries
305
- totalHandoffs, // grand total of handoff records
306
- totalRollups, // grand total of rollup entries
235
+ missingEmbeddings,
236
+ activeLedgerSummaries,
237
+ orphanedHandoffs,
238
+ staleRollups,
239
+ totalActiveEntries,
240
+ totalHandoffs,
241
+ totalRollups,
242
+ };
243
+ }
244
+ // ─── v3.0: Agent Registry (Hivemind) ───────────────────────
245
+ // Supabase users need to run the 017_agent_hivemind.sql migration
246
+ async registerAgent(entry) {
247
+ const record = {
248
+ project: entry.project,
249
+ user_id: entry.user_id,
250
+ role: entry.role,
251
+ agent_name: entry.agent_name ?? null,
252
+ status: entry.status || "active",
253
+ current_task: entry.current_task ?? null,
307
254
  };
255
+ const result = await supabasePost("agent_registry", record);
256
+ const data = Array.isArray(result) ? result[0] : result;
257
+ return { ...entry, id: data?.id, status: entry.status || "active" };
258
+ }
259
+ async heartbeatAgent(project, userId, role, currentTask) {
260
+ const patchData = {
261
+ last_heartbeat: new Date().toISOString(),
262
+ };
263
+ if (currentTask !== undefined) {
264
+ patchData.current_task = currentTask;
265
+ }
266
+ await supabasePatch("agent_registry", patchData, {
267
+ project: `eq.${project}`,
268
+ user_id: `eq.${userId}`,
269
+ role: `eq.${role}`,
270
+ });
271
+ }
272
+ async listTeam(project, userId, _staleMinutes = 30) {
273
+ const data = await supabaseGet("agent_registry", {
274
+ project: `eq.${project}`,
275
+ user_id: `eq.${userId}`,
276
+ order: "last_heartbeat.desc",
277
+ });
278
+ return (Array.isArray(data) ? data : []);
279
+ }
280
+ async deregisterAgent(project, userId, role) {
281
+ await supabaseDelete("agent_registry", {
282
+ project: `eq.${project}`,
283
+ user_id: `eq.${userId}`,
284
+ role: `eq.${role}`,
285
+ });
308
286
  }
309
287
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Agent Registry Tool Definitions (v3.0 — Agent Hivemind)
3
+ *
4
+ * Three new MCP tools for multi-agent coordination:
5
+ * - agent_register: Register an agent with project + role
6
+ * - agent_heartbeat: Update heartbeat + current task
7
+ * - agent_list_team: List active agents on a project
8
+ *
9
+ * These tools are ONLY registered when PRISM_ENABLE_HIVEMIND=true
10
+ * (see server.ts). This prevents increasing the tool count for
11
+ * users who don't need multi-agent features.
12
+ */
13
+ // ─── Role Icons (for dashboard + responses) ──────────────────
14
+ export const ROLE_ICONS = {
15
+ dev: "🛠️",
16
+ qa: "🔍",
17
+ pm: "📋",
18
+ lead: "🏗️",
19
+ security: "🔒",
20
+ ux: "🎨",
21
+ cmo: "📢",
22
+ global: "🌐",
23
+ };
24
+ /** Get icon for a role, with fallback for custom roles (Pro-Tip 4) */
25
+ export function getRoleIcon(role) {
26
+ return ROLE_ICONS[role.toLowerCase()] || "🤖";
27
+ }
28
+ // ─── Tool Definitions ────────────────────────────────────────
29
+ export const AGENT_REGISTER_TOOL = {
30
+ name: "agent_register",
31
+ description: "Register this agent with the Hivemind team for a project. " +
32
+ "Announces your role and current task to other agents. " +
33
+ "If already registered, updates the existing entry. " +
34
+ "Other agents will see you when they call agent_list_team or session_load_context.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ project: {
39
+ type: "string",
40
+ description: "Project identifier (e.g., 'prism-mcp').",
41
+ },
42
+ role: {
43
+ type: "string",
44
+ description: "Your agent role. Common roles: 'dev', 'qa', 'pm', 'lead', 'security', 'ux'. " +
45
+ "Custom roles are also supported (e.g., 'translator', 'docs').",
46
+ },
47
+ agent_name: {
48
+ type: "string",
49
+ description: "Optional human-readable name for this agent (e.g., 'Backend Dev #1').",
50
+ },
51
+ current_task: {
52
+ type: "string",
53
+ description: "Optional description of what you're currently working on.",
54
+ },
55
+ },
56
+ required: ["project", "role"],
57
+ },
58
+ };
59
+ export const AGENT_HEARTBEAT_TOOL = {
60
+ name: "agent_heartbeat",
61
+ description: "Update your heartbeat and optionally your current task. " +
62
+ "Call this periodically to stay visible to the team. " +
63
+ "Agents that haven't sent a heartbeat in 30 minutes are auto-pruned.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {
67
+ project: {
68
+ type: "string",
69
+ description: "Project identifier.",
70
+ },
71
+ role: {
72
+ type: "string",
73
+ description: "Your agent role.",
74
+ },
75
+ current_task: {
76
+ type: "string",
77
+ description: "Optional updated description of your current task.",
78
+ },
79
+ },
80
+ required: ["project", "role"],
81
+ },
82
+ };
83
+ export const AGENT_LIST_TEAM_TOOL = {
84
+ name: "agent_list_team",
85
+ description: "List all active agents on a project. Shows role, status, current task, " +
86
+ "and last heartbeat time. Automatically prunes agents that haven't " +
87
+ "reported in 30+ minutes.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ project: {
92
+ type: "string",
93
+ description: "Project identifier.",
94
+ },
95
+ },
96
+ required: ["project"],
97
+ },
98
+ };
99
+ /** All Hivemind agent registry tools — conditionally registered */
100
+ export const AGENT_REGISTRY_TOOLS = [
101
+ AGENT_REGISTER_TOOL,
102
+ AGENT_HEARTBEAT_TOOL,
103
+ AGENT_LIST_TEAM_TOOL,
104
+ ];
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Agent Registry Handlers (v3.0 — Agent Hivemind)
3
+ *
4
+ * Handler implementations for the 3 agent registry MCP tools.
5
+ * These are only called when PRISM_ENABLE_HIVEMIND=true.
6
+ */
7
+ import { getStorage } from "../storage/index.js";
8
+ import { PRISM_USER_ID } from "../config.js";
9
+ import { getRoleIcon } from "./agentRegistryDefinitions.js";
10
+ // ─── Type Guards ─────────────────────────────────────────────
11
+ function isAgentRegisterArgs(args) {
12
+ return typeof args.project === "string" && typeof args.role === "string";
13
+ }
14
+ function isAgentHeartbeatArgs(args) {
15
+ return typeof args.project === "string" && typeof args.role === "string";
16
+ }
17
+ function isAgentListTeamArgs(args) {
18
+ return typeof args.project === "string";
19
+ }
20
+ // ─── Handlers ────────────────────────────────────────────────
21
+ export async function agentRegisterHandler(args) {
22
+ if (!isAgentRegisterArgs(args)) {
23
+ return {
24
+ content: [{ type: "text", text: "Missing required: project, role" }],
25
+ isError: true,
26
+ };
27
+ }
28
+ const storage = await getStorage();
29
+ const result = await storage.registerAgent({
30
+ project: args.project,
31
+ user_id: PRISM_USER_ID,
32
+ role: args.role,
33
+ agent_name: args.agent_name || null,
34
+ status: "active",
35
+ current_task: args.current_task || null,
36
+ });
37
+ const icon = getRoleIcon(args.role);
38
+ return {
39
+ content: [{
40
+ type: "text",
41
+ text: `${icon} **Agent Registered**\n\n` +
42
+ `- **Project:** ${args.project}\n` +
43
+ `- **Role:** ${args.role}\n` +
44
+ (args.agent_name ? `- **Name:** ${args.agent_name}\n` : "") +
45
+ (args.current_task ? `- **Task:** ${args.current_task}\n` : "") +
46
+ `\nOther agents will see you when they call \`agent_list_team\` or \`session_load_context\`.`,
47
+ }],
48
+ };
49
+ }
50
+ export async function agentHeartbeatHandler(args) {
51
+ if (!isAgentHeartbeatArgs(args)) {
52
+ return {
53
+ content: [{ type: "text", text: "Missing required: project, role" }],
54
+ isError: true,
55
+ };
56
+ }
57
+ const storage = await getStorage();
58
+ await storage.heartbeatAgent(args.project, PRISM_USER_ID, args.role, args.current_task);
59
+ return {
60
+ content: [{
61
+ type: "text",
62
+ text: `💓 Heartbeat updated for **${args.role}** on \`${args.project}\`.` +
63
+ (args.current_task ? ` Task: ${args.current_task}` : ""),
64
+ }],
65
+ };
66
+ }
67
+ export async function agentListTeamHandler(args) {
68
+ if (!isAgentListTeamArgs(args)) {
69
+ return {
70
+ content: [{ type: "text", text: "Missing required: project" }],
71
+ isError: true,
72
+ };
73
+ }
74
+ const storage = await getStorage();
75
+ const team = await storage.listTeam(args.project, PRISM_USER_ID);
76
+ if (team.length === 0) {
77
+ return {
78
+ content: [{
79
+ type: "text",
80
+ text: `No active agents on \`${args.project}\`. Use \`agent_register\` to join the team.`,
81
+ }],
82
+ };
83
+ }
84
+ const lines = team.map(agent => {
85
+ const icon = getRoleIcon(agent.role);
86
+ const ago = agent.last_heartbeat
87
+ ? getTimeAgo(agent.last_heartbeat)
88
+ : "unknown";
89
+ return (`${icon} **${agent.role}**` +
90
+ (agent.agent_name ? ` (${agent.agent_name})` : "") +
91
+ ` — ${agent.status}` +
92
+ (agent.current_task ? ` | Task: ${agent.current_task}` : "") +
93
+ ` | Last seen: ${ago}`);
94
+ });
95
+ return {
96
+ content: [{
97
+ type: "text",
98
+ text: `## 🐝 Active Hivemind Team — \`${args.project}\`\n\n` +
99
+ lines.join("\n") +
100
+ `\n\n_${team.length} agent(s) active. Stale agents (>30min) auto-pruned._`,
101
+ }],
102
+ };
103
+ }
104
+ // ─── Helpers ─────────────────────────────────────────────────
105
+ function getTimeAgo(isoString) {
106
+ const diff = Date.now() - new Date(isoString).getTime();
107
+ const mins = Math.floor(diff / 60000);
108
+ if (mins < 1)
109
+ return "just now";
110
+ if (mins < 60)
111
+ return `${mins}m ago`;
112
+ const hours = Math.floor(mins / 60);
113
+ return `${hours}h ago`;
114
+ }
@@ -33,3 +33,8 @@ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContext
33
33
  // more complex than the other session memory handlers (chunked Gemini
34
34
  // API calls, recursive summarization, etc.).
35
35
  export { compactLedgerHandler } from "./compactionHandler.js";
36
+ // ── Agent Registry Tools (v3.0 — Hivemind, Optional) ──
37
+ // These tools are only registered when PRISM_ENABLE_HIVEMIND=true.
38
+ // server.ts handles the conditional registration.
39
+ export { AGENT_REGISTRY_TOOLS, getRoleIcon } from "./agentRegistryDefinitions.js";
40
+ export { agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler } from "./agentRegistryHandlers.js";
@@ -35,6 +35,10 @@ export const SESSION_SAVE_LEDGER_TOOL = {
35
35
  items: { type: "string" },
36
36
  description: "Optional list of key decisions made during this session.",
37
37
  },
38
+ role: {
39
+ type: "string",
40
+ description: "v3.0: Agent role for Hivemind scoping (e.g., 'dev', 'qa', 'pm'). Defaults to 'global'.",
41
+ },
38
42
  },
39
43
  required: ["project", "conversation_id", "summary"],
40
44
  },
@@ -84,6 +88,10 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
84
88
  type: "string",
85
89
  description: "Free-form critical context the next session needs to know.",
86
90
  },
91
+ role: {
92
+ type: "string",
93
+ description: "v3.0: Agent role for Hivemind scoping (e.g., 'dev', 'qa', 'pm'). Defaults to 'global'.",
94
+ },
87
95
  },
88
96
  required: ["project"],
89
97
  },
@@ -109,6 +117,10 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
109
117
  enum: ["quick", "standard", "deep"],
110
118
  description: "How much context to load: 'quick' (just TODOs), 'standard' (recommended — includes recent summaries), or 'deep' (full history). Default: standard.",
111
119
  },
120
+ role: {
121
+ type: "string",
122
+ description: "v3.0: Agent role for Hivemind scoping (e.g., 'dev', 'qa', 'pm'). Defaults to 'global'. When set, also injects active_team roster.",
123
+ },
112
124
  },
113
125
  required: ["project"],
114
126
  },
@@ -46,7 +46,7 @@ export async function sessionSaveLedgerHandler(args) {
46
46
  if (!isSessionSaveLedgerArgs(args)) {
47
47
  throw new Error("Invalid arguments for session_save_ledger");
48
48
  }
49
- const { project, conversation_id, summary, todos, files_changed, decisions } = args;
49
+ const { project, conversation_id, summary, todos, files_changed, decisions, role } = args;
50
50
  const storage = await getStorage();
51
51
  debugLog(`[session_save_ledger] Saving ledger entry for project="${project}"`);
52
52
  // Auto-extract keywords from summary + decisions for knowledge accumulation
@@ -63,6 +63,7 @@ export async function sessionSaveLedgerHandler(args) {
63
63
  files_changed: files_changed || [],
64
64
  decisions: decisions || [],
65
65
  keywords,
66
+ role: role || "global", // v3.0: Hivemind role scoping
66
67
  });
67
68
  // ─── Fire-and-forget embedding generation ───
68
69
  if (GOOGLE_API_KEY && result) {
@@ -104,7 +105,8 @@ export async function sessionSaveHandoffHandler(args, server) {
104
105
  if (!isSessionSaveHandoffArgs(args)) {
105
106
  throw new Error("Invalid arguments for session_save_handoff");
106
107
  }
107
- const { project, expected_version, open_todos, active_branch, last_summary, key_context, } = args;
108
+ const { project, expected_version, open_todos, active_branch, last_summary, key_context, role, // v3.0: Hivemind role
109
+ } = args;
108
110
  const storage = await getStorage();
109
111
  debugLog(`[session_save_handoff] Saving handoff for project="${project}" ` +
110
112
  `(expected_version=${expected_version ?? "none"})`);
@@ -133,6 +135,7 @@ export async function sessionSaveHandoffHandler(args, server) {
133
135
  key_context: key_context ?? null,
134
136
  active_branch: active_branch ?? null,
135
137
  metadata,
138
+ role: role || "global", // v3.0: Hivemind role scoping
136
139
  }, expected_version ?? null);
137
140
  // ─── Handle version conflict ───
138
141
  if (data.status === "conflict") {
@@ -317,7 +320,7 @@ export async function sessionLoadContextHandler(args) {
317
320
  if (!isSessionLoadContextArgs(args)) {
318
321
  throw new Error("Invalid arguments for session_load_context");
319
322
  }
320
- const { project, level = "standard" } = args;
323
+ const { project, level = "standard", role } = args;
321
324
  const validLevels = ["quick", "standard", "deep"];
322
325
  if (!validLevels.includes(level)) {
323
326
  return {
@@ -330,7 +333,7 @@ export async function sessionLoadContextHandler(args) {
330
333
  }
331
334
  debugLog(`[session_load_context] Loading ${level} context for project="${project}"`);
332
335
  const storage = await getStorage();
333
- const data = await storage.loadContext(project, level, PRISM_USER_ID);
336
+ const data = await storage.loadContext(project, level, PRISM_USER_ID, role); // v3.0: pass role
334
337
  if (!data) {
335
338
  return {
336
339
  content: [{
@@ -38,7 +38,7 @@ export async function createMcpClient() {
38
38
  command: "node",
39
39
  args: ["index.js"], // Server entry point
40
40
  });
41
- const client = new Client({ name: "gemini-mcp-client", version: "1.0.0" }, { capabilities: { tools: {} } });
41
+ const client = new Client({ name: "gemini-mcp-client", version: "1.0.0" });
42
42
  await client.connect(transport);
43
43
  return { client, transport };
44
44
  }