prism-mcp-server 2.5.2 → 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.
@@ -0,0 +1,73 @@
1
+ import { createClient } from "@libsql/client";
2
+ import { resolve } from "path";
3
+ import { homedir } from "os";
4
+ // We use a small, dedicated DB just for configuration settings.
5
+ // This solves the chicken-and-egg problem: we need to know WHICH
6
+ // storage backend to boot *before* we can use that backend.
7
+ //
8
+ // Stored in ~/.prism-mcp/prism-config.db — the same root directory
9
+ // used by sqlite.ts and autoCapture.ts for all Prism files.
10
+ //
11
+ // ⚡ BOOT SETTINGS NOTE:
12
+ // Settings in this store that affect server initialization (e.g.
13
+ // PRISM_STORAGE, PRISM_ENABLE_HIVEMIND) are read only at startup.
14
+ // Changing them at runtime requires a server restart to take effect.
15
+ // Runtime-only settings (e.g. dashboard_theme) take effect immediately.
16
+ const CONFIG_PATH = resolve(homedir(), ".prism-mcp", "prism-config.db");
17
+ let configClient = null;
18
+ let initialized = false;
19
+ function getClient() {
20
+ if (!configClient) {
21
+ configClient = createClient({
22
+ url: `file:${CONFIG_PATH}`,
23
+ });
24
+ }
25
+ return configClient;
26
+ }
27
+ export async function initConfigStorage() {
28
+ if (initialized)
29
+ return;
30
+ const client = getClient();
31
+ await client.execute(`
32
+ CREATE TABLE IF NOT EXISTS system_settings (
33
+ key TEXT PRIMARY KEY,
34
+ value TEXT NOT NULL,
35
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
36
+ )
37
+ `);
38
+ initialized = true;
39
+ }
40
+ export async function getSetting(key, defaultValue = "") {
41
+ await initConfigStorage();
42
+ const client = getClient();
43
+ const rs = await client.execute({
44
+ sql: "SELECT value FROM system_settings WHERE key = ?",
45
+ args: [key],
46
+ });
47
+ if (rs.rows.length > 0) {
48
+ return rs.rows[0].value;
49
+ }
50
+ return defaultValue;
51
+ }
52
+ export async function setSetting(key, value) {
53
+ await initConfigStorage();
54
+ const client = getClient();
55
+ await client.execute({
56
+ sql: `
57
+ INSERT INTO system_settings (key, value, updated_at)
58
+ VALUES (?, ?, CURRENT_TIMESTAMP)
59
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
60
+ `,
61
+ args: [key, value],
62
+ });
63
+ }
64
+ export async function getAllSettings() {
65
+ await initConfigStorage();
66
+ const client = getClient();
67
+ const rs = await client.execute("SELECT key, value FROM system_settings");
68
+ const settings = {};
69
+ for (const row of rs.rows) {
70
+ settings[row.key] = row.value;
71
+ }
72
+ return settings;
73
+ }
@@ -9,10 +9,12 @@
9
9
  * const storage = await getStorage();
10
10
  * // Pass `storage` to all session memory handlers
11
11
  */
12
- import { PRISM_STORAGE } from "../config.js";
12
+ import { PRISM_STORAGE as ENV_PRISM_STORAGE } from "../config.js";
13
13
  import { debugLog } from "../utils/logger.js";
14
14
  import { SupabaseStorage } from "./supabase.js";
15
+ import { getSetting } from "./configStorage.js";
15
16
  let storageInstance = null;
17
+ export let activeStorageBackend = "local";
16
18
  /**
17
19
  * Returns the singleton storage backend.
18
20
  *
@@ -25,16 +27,17 @@ let storageInstance = null;
25
27
  export async function getStorage() {
26
28
  if (storageInstance)
27
29
  return storageInstance;
28
- debugLog(`[Prism Storage] Initializing backend: ${PRISM_STORAGE}`);
29
- if (PRISM_STORAGE === "local") {
30
+ activeStorageBackend = await getSetting("PRISM_STORAGE", ENV_PRISM_STORAGE);
31
+ debugLog(`[Prism Storage] Initializing backend: ${activeStorageBackend}`);
32
+ if (activeStorageBackend === "local") {
30
33
  const { SqliteStorage } = await import("./sqlite.js");
31
34
  storageInstance = new SqliteStorage();
32
35
  }
33
- else if (PRISM_STORAGE === "supabase") {
36
+ else if (activeStorageBackend === "supabase") {
34
37
  storageInstance = new SupabaseStorage();
35
38
  }
36
39
  else {
37
- throw new Error(`Unknown PRISM_STORAGE value: "${PRISM_STORAGE}". ` +
40
+ throw new Error(`Unknown PRISM_STORAGE value: "${activeStorageBackend}". ` +
38
41
  `Must be "local" or "supabase".`);
39
42
  }
40
43
  await storageInstance.initialize();
@@ -182,6 +182,92 @@ export class SqliteStorage {
182
182
  // Index for fast WHERE deleted_at IS NULL queries.
183
183
  // CREATE INDEX IF NOT EXISTS is safe to run repeatedly (no try/catch needed).
184
184
  await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_ledger_deleted ON session_ledger(deleted_at)`);
185
+ // ─── v3.0 Migration: Agent Hivemind ──────────────────────────
186
+ //
187
+ // 1. Add `role` column to session_ledger (simple ALTER TABLE — no UNIQUE issue)
188
+ // 2. Rebuild session_handoffs with new UNIQUE(project, user_id, role)
189
+ // Using 'global' default instead of NULL to avoid SQLite NULL uniqueness trap.
190
+ // 3. Create agent_registry table
191
+ // session_ledger: simple column add
192
+ try {
193
+ await this.db.execute(`ALTER TABLE session_ledger ADD COLUMN role TEXT NOT NULL DEFAULT 'global'`);
194
+ debugLog("[SqliteStorage] v3.0 migration: added role column to session_ledger");
195
+ }
196
+ catch (e) {
197
+ if (!e.message?.includes("duplicate column name"))
198
+ throw e;
199
+ }
200
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_ledger_role ON session_ledger(role)`);
201
+ // session_handoffs: 4-step table rebuild for UNIQUE constraint change
202
+ // Check if we need to do the rebuild by looking for the role column
203
+ try {
204
+ await this.db.execute(`SELECT role FROM session_handoffs LIMIT 1`);
205
+ // Column exists — migration already ran
206
+ }
207
+ catch {
208
+ // Column doesn't exist — do the table rebuild
209
+ debugLog("[SqliteStorage] v3.0 migration: rebuilding session_handoffs with role column");
210
+ // Step 1: Create new table with correct constraint
211
+ await this.db.execute(`
212
+ CREATE TABLE session_handoffs_v2 (
213
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
214
+ project TEXT NOT NULL,
215
+ user_id TEXT NOT NULL DEFAULT 'default',
216
+ role TEXT NOT NULL DEFAULT 'global',
217
+ last_summary TEXT DEFAULT NULL,
218
+ pending_todo TEXT DEFAULT '[]',
219
+ active_decisions TEXT DEFAULT '[]',
220
+ keywords TEXT DEFAULT '[]',
221
+ key_context TEXT DEFAULT NULL,
222
+ active_branch TEXT DEFAULT NULL,
223
+ version INTEGER NOT NULL DEFAULT 1,
224
+ metadata TEXT DEFAULT '{}',
225
+ created_at TEXT DEFAULT (datetime('now')),
226
+ updated_at TEXT DEFAULT (datetime('now')),
227
+ UNIQUE(project, user_id, role)
228
+ )
229
+ `);
230
+ // Step 2: Copy data with explicit column names (Pro-Tip 2)
231
+ await this.db.execute(`
232
+ INSERT INTO session_handoffs_v2
233
+ (id, project, user_id, role, last_summary, pending_todo,
234
+ active_decisions, keywords, key_context, active_branch,
235
+ version, metadata, created_at, updated_at)
236
+ SELECT
237
+ id, project, user_id, 'global', last_summary, pending_todo,
238
+ active_decisions, keywords, key_context, active_branch,
239
+ version, metadata, created_at, updated_at
240
+ FROM session_handoffs
241
+ `);
242
+ // Step 3: Drop old and rename
243
+ await this.db.execute(`DROP TABLE session_handoffs`);
244
+ await this.db.execute(`ALTER TABLE session_handoffs_v2 RENAME TO session_handoffs`);
245
+ debugLog("[SqliteStorage] v3.0 migration: session_handoffs rebuilt with UNIQUE(project, user_id, role)");
246
+ }
247
+ // agent_registry: new table for Hivemind coordination
248
+ await this.db.execute(`
249
+ CREATE TABLE IF NOT EXISTS agent_registry (
250
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
251
+ project TEXT NOT NULL,
252
+ user_id TEXT NOT NULL DEFAULT 'default',
253
+ role TEXT NOT NULL,
254
+ agent_name TEXT DEFAULT NULL,
255
+ status TEXT NOT NULL DEFAULT 'active',
256
+ current_task TEXT DEFAULT NULL,
257
+ last_heartbeat TEXT DEFAULT (datetime('now')),
258
+ created_at TEXT DEFAULT (datetime('now')),
259
+ UNIQUE(project, user_id, role)
260
+ )
261
+ `);
262
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_registry_project ON agent_registry(project, user_id)`);
263
+ // system_settings: key-value store for dashboard runtime settings (v3.0)
264
+ await this.db.execute(`
265
+ CREATE TABLE IF NOT EXISTS system_settings (
266
+ key TEXT PRIMARY KEY,
267
+ value TEXT NOT NULL,
268
+ updated_at TEXT DEFAULT (datetime('now'))
269
+ )
270
+ `);
185
271
  }
186
272
  // ─── PostgREST Filter Parser ───────────────────────────────
187
273
  //
@@ -297,14 +383,15 @@ export class SqliteStorage {
297
383
  const now = new Date().toISOString();
298
384
  await this.db.execute({
299
385
  sql: `INSERT INTO session_ledger
300
- (id, project, conversation_id, user_id, summary, todos, files_changed,
386
+ (id, project, conversation_id, user_id, role, summary, todos, files_changed,
301
387
  decisions, keywords, is_rollup, rollup_count, title, agent_name, created_at, session_date)
302
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
303
389
  args: [
304
390
  id,
305
391
  entry.project,
306
392
  entry.conversation_id,
307
393
  entry.user_id,
394
+ entry.role || "global", // v3.0: default to 'global'
308
395
  entry.summary,
309
396
  JSON.stringify(entry.todos || []),
310
397
  JSON.stringify(entry.files_changed || []),
@@ -404,19 +491,21 @@ export class SqliteStorage {
404
491
  }
405
492
  // ─── Handoff Operations (OCC) ──────────────────────────────
406
493
  async saveHandoff(handoff, expectedVersion) {
494
+ const role = handoff.role || "global"; // v3.0: default to 'global'
407
495
  // CASE 1: No expectedVersion → UPSERT (create or force-update)
408
496
  if (expectedVersion === null || expectedVersion === undefined) {
409
497
  // Try INSERT first
410
498
  try {
411
499
  await this.db.execute({
412
500
  sql: `INSERT INTO session_handoffs
413
- (id, project, user_id, last_summary, pending_todo, active_decisions,
501
+ (id, project, user_id, role, last_summary, pending_todo, active_decisions,
414
502
  keywords, key_context, active_branch, version, metadata)
415
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
503
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
416
504
  args: [
417
505
  randomUUID(),
418
506
  handoff.project,
419
507
  handoff.user_id,
508
+ role,
420
509
  handoff.last_summary ?? null,
421
510
  JSON.stringify(handoff.pending_todo ?? []),
422
511
  JSON.stringify(handoff.active_decisions ?? []),
@@ -437,7 +526,7 @@ export class SqliteStorage {
437
526
  SET last_summary = ?, pending_todo = ?, active_decisions = ?,
438
527
  keywords = ?, key_context = ?, active_branch = ?,
439
528
  metadata = ?, version = version + 1, updated_at = datetime('now')
440
- WHERE project = ? AND user_id = ?
529
+ WHERE project = ? AND user_id = ? AND role = ?
441
530
  RETURNING version`,
442
531
  args: [
443
532
  handoff.last_summary ?? null,
@@ -449,6 +538,7 @@ export class SqliteStorage {
449
538
  JSON.stringify(handoff.metadata ?? {}),
450
539
  handoff.project,
451
540
  handoff.user_id,
541
+ role,
452
542
  ],
453
543
  });
454
544
  const newVersion = result.rows[0]?.version;
@@ -463,7 +553,7 @@ export class SqliteStorage {
463
553
  SET last_summary = ?, pending_todo = ?, active_decisions = ?,
464
554
  keywords = ?, key_context = ?, active_branch = ?,
465
555
  metadata = ?, version = version + 1, updated_at = datetime('now')
466
- WHERE project = ? AND user_id = ? AND version = ?
556
+ WHERE project = ? AND user_id = ? AND role = ? AND version = ?
467
557
  RETURNING version`,
468
558
  args: [
469
559
  handoff.last_summary ?? null,
@@ -475,14 +565,15 @@ export class SqliteStorage {
475
565
  JSON.stringify(handoff.metadata ?? {}),
476
566
  handoff.project,
477
567
  handoff.user_id,
568
+ role,
478
569
  expectedVersion,
479
570
  ],
480
571
  });
481
572
  if (result.rows.length === 0) {
482
573
  // Version mismatch — detect the actual current version
483
574
  const check = await this.db.execute({
484
- sql: "SELECT version FROM session_handoffs WHERE project = ? AND user_id = ?",
485
- args: [handoff.project, handoff.user_id],
575
+ sql: "SELECT version FROM session_handoffs WHERE project = ? AND user_id = ? AND role = ?",
576
+ args: [handoff.project, handoff.user_id, role],
486
577
  });
487
578
  if (check.rows.length > 0) {
488
579
  return {
@@ -505,11 +596,13 @@ export class SqliteStorage {
505
596
  });
506
597
  }
507
598
  // ─── Load Context (Progressive) ────────────────────────────
508
- async loadContext(project, level, userId) {
509
- // Fetch handoff state
599
+ async loadContext(project, level, userId, role // v3.0: optional role filter
600
+ ) {
601
+ const effectiveRole = role || "global";
602
+ // Fetch handoff state (role-scoped)
510
603
  const handoffResult = await this.db.execute({
511
- sql: "SELECT * FROM session_handoffs WHERE project = ? AND user_id = ?",
512
- args: [project, userId],
604
+ sql: "SELECT * FROM session_handoffs WHERE project = ? AND user_id = ? AND role = ?",
605
+ args: [project, userId, effectiveRole],
513
606
  });
514
607
  if (handoffResult.rows.length === 0)
515
608
  return null;
@@ -517,6 +610,7 @@ export class SqliteStorage {
517
610
  // Base context (always returned)
518
611
  const context = {
519
612
  project: handoff.project,
613
+ role: handoff.role, // v3.0: include role in response
520
614
  keywords: this.parseJsonColumn(handoff.keywords),
521
615
  pending_todo: this.parseJsonColumn(handoff.pending_todo),
522
616
  version: handoff.version,
@@ -531,32 +625,56 @@ export class SqliteStorage {
531
625
  context.active_branch = handoff.active_branch;
532
626
  context.key_context = handoff.key_context;
533
627
  if (level === "standard") {
534
- // Add recent ledger entries as summaries
535
- // Phase 2: AND deleted_at IS NULL — exclude soft-deleted entries
628
+ // Add recent ledger entries (role-scoped)
536
629
  const recentLedger = await this.db.execute({
537
630
  sql: `SELECT summary, decisions, session_date, created_at
538
631
  FROM session_ledger
539
- WHERE project = ? AND user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
632
+ WHERE project = ? AND user_id = ? AND role = ?
633
+ AND archived_at IS NULL AND deleted_at IS NULL
540
634
  ORDER BY created_at DESC
541
635
  LIMIT 5`,
542
- args: [project, userId],
636
+ args: [project, userId, effectiveRole],
543
637
  });
544
638
  context.recent_sessions = recentLedger.rows.map(r => ({
545
639
  summary: r.summary,
546
640
  decisions: this.parseJsonColumn(r.decisions),
547
641
  session_date: r.session_date || r.created_at,
548
642
  }));
643
+ // v3.0: Team Roster injection — show active teammates
644
+ if (role && role !== "global") {
645
+ try {
646
+ const teamResult = await this.db.execute({
647
+ sql: `SELECT role, status, current_task, last_heartbeat
648
+ FROM agent_registry
649
+ WHERE project = ? AND user_id = ? AND role != ?
650
+ AND last_heartbeat > datetime('now', '-30 minutes')
651
+ ORDER BY last_heartbeat DESC`,
652
+ args: [project, userId, role],
653
+ });
654
+ if (teamResult.rows.length > 0) {
655
+ context.active_team = teamResult.rows.map(r => ({
656
+ role: r.role,
657
+ status: r.status,
658
+ current_task: r.current_task,
659
+ last_heartbeat: r.last_heartbeat,
660
+ }));
661
+ }
662
+ }
663
+ catch {
664
+ // agent_registry may not exist yet — graceful degradation
665
+ }
666
+ }
549
667
  return context;
550
668
  }
551
- // Deep: add full session history
552
- // Phase 2: AND deleted_at IS NULL — exclude soft-deleted entries
669
+ // Deep: add full session history (role-scoped)
553
670
  const fullLedger = await this.db.execute({
554
671
  sql: `SELECT summary, decisions, files_changed, todos, session_date, created_at
555
672
  FROM session_ledger
556
- WHERE project = ? AND user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
673
+ WHERE project = ? AND user_id = ? AND role = ?
674
+ AND archived_at IS NULL AND deleted_at IS NULL
557
675
  ORDER BY created_at DESC
558
676
  LIMIT 50`,
559
- args: [project, userId],
677
+ args: [project, userId, effectiveRole],
560
678
  });
561
679
  context.session_history = fullLedger.rows.map(r => ({
562
680
  summary: r.summary,
@@ -913,4 +1031,103 @@ export class SqliteStorage {
913
1031
  totalRollups, // grand total of rollup entries
914
1032
  };
915
1033
  }
1034
+ // ─── v3.0: Agent Registry (Hivemind) ───────────────────────
1035
+ async registerAgent(entry) {
1036
+ const id = randomUUID();
1037
+ const role = entry.role;
1038
+ const status = entry.status || "active";
1039
+ try {
1040
+ // Try INSERT first
1041
+ await this.db.execute({
1042
+ sql: `INSERT INTO agent_registry
1043
+ (id, project, user_id, role, agent_name, status, current_task)
1044
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1045
+ args: [
1046
+ id,
1047
+ entry.project,
1048
+ entry.user_id,
1049
+ role,
1050
+ entry.agent_name ?? null,
1051
+ status,
1052
+ entry.current_task ?? null,
1053
+ ],
1054
+ });
1055
+ debugLog(`[SqliteStorage] Agent registered: ${entry.project}/${role}`);
1056
+ return { ...entry, id, status };
1057
+ }
1058
+ catch (err) {
1059
+ // UNIQUE constraint → update existing
1060
+ const msg = err instanceof Error ? err.message : String(err);
1061
+ if (msg.includes("UNIQUE") || msg.includes("constraint")) {
1062
+ await this.db.execute({
1063
+ sql: `UPDATE agent_registry
1064
+ SET agent_name = ?, status = ?, current_task = ?,
1065
+ last_heartbeat = datetime('now')
1066
+ WHERE project = ? AND user_id = ? AND role = ?`,
1067
+ args: [
1068
+ entry.agent_name ?? null,
1069
+ status,
1070
+ entry.current_task ?? null,
1071
+ entry.project,
1072
+ entry.user_id,
1073
+ role,
1074
+ ],
1075
+ });
1076
+ debugLog(`[SqliteStorage] Agent re-registered: ${entry.project}/${role}`);
1077
+ return { ...entry, status };
1078
+ }
1079
+ throw err;
1080
+ }
1081
+ }
1082
+ async heartbeatAgent(project, userId, role, currentTask) {
1083
+ const setClauses = ["last_heartbeat = datetime('now')"];
1084
+ const args = [];
1085
+ if (currentTask !== undefined) {
1086
+ setClauses.push("current_task = ?");
1087
+ args.push(currentTask);
1088
+ }
1089
+ args.push(project, userId, role);
1090
+ await this.db.execute({
1091
+ sql: `UPDATE agent_registry
1092
+ SET ${setClauses.join(", ")}
1093
+ WHERE project = ? AND user_id = ? AND role = ?`,
1094
+ args,
1095
+ });
1096
+ }
1097
+ async listTeam(project, userId, staleMinutes = 30) {
1098
+ // Auto-prune stale agents first
1099
+ await this.db.execute({
1100
+ sql: `DELETE FROM agent_registry
1101
+ WHERE project = ? AND user_id = ?
1102
+ AND last_heartbeat < datetime('now', '-' || ? || ' minutes')`,
1103
+ args: [project, userId, staleMinutes],
1104
+ });
1105
+ // Fetch remaining active agents
1106
+ const result = await this.db.execute({
1107
+ sql: `SELECT id, project, user_id, role, agent_name, status,
1108
+ current_task, last_heartbeat, created_at
1109
+ FROM agent_registry
1110
+ WHERE project = ? AND user_id = ?
1111
+ ORDER BY last_heartbeat DESC`,
1112
+ args: [project, userId],
1113
+ });
1114
+ return result.rows.map(r => ({
1115
+ id: r.id,
1116
+ project: r.project,
1117
+ user_id: r.user_id,
1118
+ role: r.role,
1119
+ agent_name: r.agent_name,
1120
+ status: r.status,
1121
+ current_task: r.current_task,
1122
+ last_heartbeat: r.last_heartbeat,
1123
+ created_at: r.created_at,
1124
+ }));
1125
+ }
1126
+ async deregisterAgent(project, userId, role) {
1127
+ await this.db.execute({
1128
+ sql: "DELETE FROM agent_registry WHERE project = ? AND user_id = ? AND role = ?",
1129
+ args: [project, userId, role],
1130
+ });
1131
+ debugLog(`[SqliteStorage] Agent deregistered: ${project}/${role}`);
1132
+ }
916
1133
  }