prism-mcp-server 9.2.3 β†’ 9.2.5

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 CHANGED
@@ -789,8 +789,9 @@ The Generator strips the `console.log`, resubmits, and the next `EVALUATE` retur
789
789
 
790
790
  ## πŸ†• What's New
791
791
 
792
- > **Current release: v9.2.3 β€” Code Review Hardening**
792
+ > **Current release: v9.2.4 β€” Cross-Backend Reconciliation**
793
793
 
794
+ - πŸ”„ **v9.2.4 β€” Cross-Backend Reconciliation:** Automatic two-layer sync from Supabase β†’ SQLite on startup. When Claude Desktop writes handoffs and ledger entries to Supabase, Antigravity (local SQLite) now automatically detects stale data and pulls newer handoffs + the 20 most recent ledger entries. 5-second timeout prevents startup freeze. Targeted ID lookups (not full table scans) keep it safe for large databases. 13 tests including malformed JSON resilience, multi-role dedup, and timeout handling.
794
795
  - πŸ”§ **v9.2.3 β€” Code Review Hardening:** 10x faster split-brain detection (lightweight direct queries replace full `StorageBackend` construction), variable shadowing fix in CLI, resource leak fix in SQLite alternate client.
795
796
  - 🚨 **v9.2.2 β€” Critical: Split-Brain Detection & Prevention:** When multiple MCP clients use different storage backends (e.g., Claude Desktop β†’ Supabase, Antigravity β†’ SQLite), session state could silently diverge, causing agents to act on stale TODOs and outdated context. **New: `--storage` flag** on `prism load` CLI lets callers explicitly select which backend to read from. **New: Split-Brain Drift Detection** in `session_load_context` β€” compares active and alternate backend versions at load time and warns prominently when they diverge. Session loader script updated to respect `PRISM_STORAGE` environment variable.
796
797
  - πŸ’» **v9.2.1 β€” CLI Full Feature Parity:** `prism load` text mode now delegates to the real `session_load_context` handler, giving CLI-only users the same enriched output as MCP clients: morning briefings, reality drift detection, SDM intuitive recall, visual memory index, role-scoped skill injection, behavioral warnings, importance scores, and agent identity. JSON mode now includes `agent_name` from dashboard settings. Session loader script PATH fix for Homebrew/nvm/volta environments.
@@ -1220,10 +1221,14 @@ Prism MCP is open-source and free for individual developers. For teams and enter
1220
1221
 
1221
1222
  ## πŸ“¦ Milestones & Roadmap
1222
1223
 
1223
- > **Current: v9.1.0** β€” Task Router v2 & Local Agent Hardening ([CHANGELOG](CHANGELOG.md))
1224
+ > **Current: v9.2.4** β€” Cross-Backend Reconciliation ([CHANGELOG](CHANGELOG.md))
1224
1225
 
1225
1226
  | Release | Headline |
1226
1227
  |---------|----------|
1228
+ | **v9.2.4** | πŸ”„ Cross-Backend Reconciliation β€” automatic Supabase β†’ SQLite sync on startup, two-layer (handoff + ledger), 5s timeout, 13 tests |
1229
+ | **v9.2.3** | πŸ”§ Code Review Hardening β€” 10x faster split-brain detection, variable shadowing fix, resource leak fix |
1230
+ | **v9.2.2** | 🚨 Split-Brain Detection & Prevention β€” `--storage` flag, drift detection, session loader hardening |
1231
+ | **v9.2.1** | πŸ’» CLI Full Feature Parity β€” text mode enrichments, agent identity, PATH fix |
1227
1232
  | **v9.1.0** | 🚦 Task Router v2 β€” file-type routing signal, 6-signal heuristics, local agent streaming buffer |
1228
1233
  | **v9.0.5** | πŸ”’ JWKS Auth Security Hardening β€” audience/issuer validation, JWT failure logging, typed agent identity |
1229
1234
  | **v9.0** | 🧠 Autonomous Cognitive OS β€” Surprisal Gate, Cognitive Budget, Affect-Tagged Memory |
@@ -60,6 +60,51 @@ export async function getStorage() {
60
60
  `Must be "local" or "supabase".`);
61
61
  }
62
62
  await storageInstance.initialize();
63
+ // ─── v9.2.4: Cross-Backend Handoff Reconciliation ──────────────
64
+ // When running on local SQLite but Supabase credentials exist,
65
+ // pull any newer handoffs from Supabase into SQLite. This fixes
66
+ // the split-brain where Claude Desktop writes go to Supabase but
67
+ // Antigravity reads from SQLite and sees stale data.
68
+ //
69
+ // IMPORTANT: The supabaseReady check above only resolves dashboard
70
+ // credentials when requestedBackend==="supabase". For reconciliation
71
+ // we need credentials even when backend is "local", so we do a
72
+ // second probe here.
73
+ if (activeStorageBackend === "local") {
74
+ let canReconcile = supabaseReady;
75
+ if (!canReconcile) {
76
+ // Probe dashboard config for Supabase credentials
77
+ const dashUrl = await getSetting("SUPABASE_URL");
78
+ const dashKey = await getSetting("SUPABASE_KEY");
79
+ if (dashUrl && dashKey) {
80
+ try {
81
+ const parsed = new URL(dashUrl);
82
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
83
+ canReconcile = true;
84
+ process.env.SUPABASE_URL = dashUrl;
85
+ process.env.SUPABASE_KEY = dashKey;
86
+ debugLog("[Prism Storage] Reconciliation: using Supabase credentials from dashboard config");
87
+ }
88
+ }
89
+ catch {
90
+ // Invalid URL β€” skip reconciliation
91
+ }
92
+ }
93
+ }
94
+ if (canReconcile) {
95
+ try {
96
+ const { reconcileHandoffs } = await import("./reconcile.js");
97
+ const { SqliteStorage } = await import("./sqlite.js");
98
+ const sqliteInstance = storageInstance;
99
+ const getTimestamps = () => sqliteInstance.getHandoffTimestamps();
100
+ await reconcileHandoffs(storageInstance, getTimestamps);
101
+ }
102
+ catch (err) {
103
+ // Non-fatal: reconciliation is best-effort
104
+ debugLog(`[Prism Storage] Reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`);
105
+ }
106
+ }
107
+ }
63
108
  return storageInstance;
64
109
  }
65
110
  /**
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Cross-Backend Handoff & Ledger Reconciliation (v9.2.4)
3
+ *
4
+ * Fixes the split-brain data inconsistency where writes made via
5
+ * Claude Desktop (Supabase) are invisible to Antigravity (local SQLite).
6
+ *
7
+ * SYNCS TWO LAYERS:
8
+ * 1. session_handoffs β€” latest project state (TODOs, summary, decisions)
9
+ * 2. session_ledger β€” recent session history (used by standard/deep loads)
10
+ *
11
+ * WHEN THIS RUNS:
12
+ * - Automatically during getStorage() initialization when:
13
+ * 1. The active backend is "local" (SQLite), AND
14
+ * 2. Supabase credentials are available (env or dashboard config)
15
+ *
16
+ * PERFORMANCE:
17
+ * - 2 Supabase REST calls per synced project:
18
+ * - session_handoffs: 1-5 rows (~1KB) β†’ instant
19
+ * - session_ledger: last 20 entries per stale project (~50KB) β†’ fast
20
+ * - Local SQLite: bulk timestamp check + targeted ID lookups + N upserts
21
+ * - Total: ~300-800ms (dominated by network, not DB)
22
+ * - Safe for databases with millions of entries β€” scoped queries only
23
+ *
24
+ * DESIGN:
25
+ * - Read-only on Supabase (never writes to remote)
26
+ * - Last-writer-wins by updated_at/created_at timestamp
27
+ * - Non-blocking: wrapped in try/catch, errors downgraded to debug log
28
+ * - Idempotent: safe to run on every boot (ledger uses ID dedup)
29
+ * - 5-second timeout on Supabase calls to prevent startup freeze
30
+ */
31
+ import { supabaseGet } from "../utils/supabaseApi.js";
32
+ import { debugLog } from "../utils/logger.js";
33
+ import { PRISM_USER_ID } from "../config.js";
34
+ /** Timeout for each Supabase REST call (ms). Prevents startup freeze. */
35
+ const RECONCILE_TIMEOUT_MS = 5_000;
36
+ /**
37
+ * Safely parse a JSON array field from Supabase.
38
+ * Handles: arrays (pass-through), JSON strings (parse), garbage (empty array).
39
+ * Never throws.
40
+ */
41
+ function safeParseArray(val) {
42
+ if (Array.isArray(val))
43
+ return val;
44
+ if (typeof val === "string") {
45
+ try {
46
+ const parsed = JSON.parse(val);
47
+ return Array.isArray(parsed) ? parsed : [];
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ return [];
54
+ }
55
+ /**
56
+ * Wrap a promise with a timeout. Rejects with AbortError if exceeded.
57
+ */
58
+ function withTimeout(promise, ms, label) {
59
+ return new Promise((resolve, reject) => {
60
+ const timer = setTimeout(() => reject(new Error(`[Reconcile] Timeout after ${ms}ms: ${label}`)), ms);
61
+ promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); });
62
+ });
63
+ }
64
+ /**
65
+ * Pull newer handoffs AND recent ledger entries from Supabase into local SQLite.
66
+ *
67
+ * @param localStorage - The initialized SqliteStorage instance
68
+ * @param getLocalTimestamps - Function to bulk-read local handoff timestamps
69
+ * @returns Summary of what was synced
70
+ */
71
+ export async function reconcileHandoffs(localStorage, getLocalTimestamps) {
72
+ const result = { checked: 0, synced: 0, projects: [], ledgerEntriesSynced: 0 };
73
+ try {
74
+ // ═══════════════════════════════════════════════════════════
75
+ // LAYER 1: Handoff Reconciliation (session_handoffs)
76
+ // ═══════════════════════════════════════════════════════════
77
+ // Step 1: Fetch all handoffs from Supabase (single REST call, ~1-5 rows)
78
+ // Timeout prevents startup freeze if Supabase is slow/unreachable.
79
+ const remoteHandoffs = await withTimeout(supabaseGet("session_handoffs", {
80
+ user_id: `eq.${PRISM_USER_ID}`,
81
+ select: "*",
82
+ }), RECONCILE_TIMEOUT_MS, "fetch handoffs");
83
+ if (!Array.isArray(remoteHandoffs) || remoteHandoffs.length === 0) {
84
+ debugLog("[Reconcile] No remote handoffs found β€” nothing to sync");
85
+ return result;
86
+ }
87
+ result.checked = remoteHandoffs.length;
88
+ // Step 2: Get all local handoff timestamps in one query (not per-project)
89
+ let localTimestamps;
90
+ if (getLocalTimestamps) {
91
+ localTimestamps = await getLocalTimestamps();
92
+ }
93
+ else {
94
+ // Fallback: empty map means all remotes will be synced
95
+ localTimestamps = new Map();
96
+ }
97
+ // Step 3: Compare and sync only stale handoffs
98
+ // Use a Set to deduplicate projects with multiple roles (FIX #6)
99
+ const syncedProjectsSet = new Set();
100
+ for (const remote of remoteHandoffs) {
101
+ const project = remote.project;
102
+ const role = remote.role || "global";
103
+ const key = `${project}::${role}`;
104
+ const remoteUpdatedAt = remote.updated_at;
105
+ const localUpdatedAt = localTimestamps.get(key);
106
+ // Sync if: local doesn't exist, or remote is newer
107
+ const needsSync = !localUpdatedAt
108
+ || (remoteUpdatedAt && new Date(remoteUpdatedAt) > new Date(localUpdatedAt));
109
+ if (needsSync) {
110
+ // FIX #4: safeParseArray prevents JSON.parse crash from aborting all projects
111
+ await localStorage.saveHandoff({
112
+ project,
113
+ user_id: PRISM_USER_ID,
114
+ role,
115
+ last_summary: remote.last_summary ?? null,
116
+ pending_todo: safeParseArray(remote.pending_todo),
117
+ active_decisions: safeParseArray(remote.active_decisions),
118
+ keywords: safeParseArray(remote.keywords),
119
+ key_context: remote.key_context ?? null,
120
+ active_branch: remote.active_branch ?? null,
121
+ metadata: typeof remote.metadata === "object" && remote.metadata !== null ? remote.metadata : {},
122
+ });
123
+ result.synced++;
124
+ result.projects.push(project);
125
+ syncedProjectsSet.add(project); // FIX #6: dedup multi-role projects
126
+ debugLog(`[Reconcile] Synced handoff "${project}" (role: ${role}) β€” ` +
127
+ `remote: ${remoteUpdatedAt}, local: ${localUpdatedAt || "missing"}`);
128
+ }
129
+ }
130
+ // ═══════════════════════════════════════════════════════════
131
+ // LAYER 2: Recent Ledger Reconciliation (session_ledger)
132
+ //
133
+ // For any project whose handoff was stale, also pull recent
134
+ // ledger entries so that standard/deep context loads include
135
+ // session history written via Supabase.
136
+ //
137
+ // We only pull the last 20 entries per project (not the full
138
+ // history) β€” this covers standard/deep context needs without
139
+ // doing a bulk data migration.
140
+ // ═══════════════════════════════════════════════════════════
141
+ if (syncedProjectsSet.size > 0) {
142
+ result.ledgerEntriesSynced = await reconcileLedger(localStorage, [...syncedProjectsSet]);
143
+ }
144
+ if (result.synced > 0) {
145
+ // FIX #7: Use debugLog instead of console.error for non-error output
146
+ debugLog(`[Prism Reconcile] Synced ${result.synced} handoff(s)` +
147
+ `${result.ledgerEntriesSynced > 0 ? ` + ${result.ledgerEntriesSynced} ledger entries` : ""}` +
148
+ ` from Supabase β†’ SQLite: ${result.projects.join(", ")}`);
149
+ }
150
+ else {
151
+ debugLog("[Reconcile] All local data is up-to-date with Supabase");
152
+ }
153
+ }
154
+ catch (err) {
155
+ // Non-fatal: log and continue. Supabase may be unreachable (offline mode).
156
+ debugLog(`[Reconcile] Failed to reconcile (non-fatal): ` +
157
+ `${err instanceof Error ? err.message : String(err)}`);
158
+ }
159
+ return result;
160
+ }
161
+ /**
162
+ * Pull recent ledger entries from Supabase for the given projects.
163
+ *
164
+ * Uses targeted ID lookup for dedup: only queries the specific IDs
165
+ * returned from Supabase, not the entire local ledger. (FIX #2)
166
+ *
167
+ * @param localStorage - The initialized StorageBackend (SQLite)
168
+ * @param projects - Deduplicated list of projects with stale handoffs
169
+ * @returns Number of ledger entries synced
170
+ */
171
+ async function reconcileLedger(localStorage, projects) {
172
+ let totalSynced = 0;
173
+ for (const project of projects) {
174
+ try {
175
+ // Fetch the 20 most recent ledger entries for this project
176
+ // Timeout prevents hang if Supabase is slow (FIX #3)
177
+ const remoteLedger = await withTimeout(supabaseGet("session_ledger", {
178
+ user_id: `eq.${PRISM_USER_ID}`,
179
+ project: `eq.${project}`,
180
+ archived_at: "is.null",
181
+ deleted_at: "is.null",
182
+ select: "id,project,conversation_id,summary,user_id,role,todos,files_changed,decisions,keywords,event_type,importance,created_at,session_date",
183
+ order: "created_at.desc",
184
+ limit: "20",
185
+ }), RECONCILE_TIMEOUT_MS, `fetch ledger for ${project}`);
186
+ if (!Array.isArray(remoteLedger) || remoteLedger.length === 0) {
187
+ continue;
188
+ }
189
+ // FIX #2: Only query the specific IDs we need to check β€” not the entire ledger.
190
+ // This is O(remote_count) not O(total_ledger_entries).
191
+ const remoteIds = remoteLedger.map(e => e.id);
192
+ const existingEntries = await localStorage.getLedgerEntries({
193
+ ids: remoteIds,
194
+ select: "id",
195
+ });
196
+ const existingIds = new Set((Array.isArray(existingEntries) ? existingEntries : [])
197
+ .map((e) => e.id));
198
+ // Insert only entries that don't exist locally
199
+ for (const entry of remoteLedger) {
200
+ if (existingIds.has(entry.id)) {
201
+ continue; // Already exists locally
202
+ }
203
+ try {
204
+ await localStorage.saveLedger({
205
+ id: entry.id,
206
+ project: entry.project,
207
+ conversation_id: entry.conversation_id || "reconciled",
208
+ summary: entry.summary,
209
+ user_id: PRISM_USER_ID,
210
+ role: entry.role || "global",
211
+ todos: safeParseArray(entry.todos),
212
+ files_changed: safeParseArray(entry.files_changed),
213
+ decisions: safeParseArray(entry.decisions),
214
+ keywords: safeParseArray(entry.keywords),
215
+ event_type: entry.event_type || "session",
216
+ importance: entry.importance || 0,
217
+ });
218
+ totalSynced++;
219
+ }
220
+ catch (insertErr) {
221
+ // Skip entries that fail (e.g., UNIQUE constraint = already exists)
222
+ const msg = insertErr instanceof Error ? insertErr.message : String(insertErr);
223
+ if (!msg.includes("UNIQUE") && !msg.includes("constraint")) {
224
+ debugLog(`[Reconcile] Failed to insert ledger entry ${entry.id}: ${msg}`);
225
+ }
226
+ }
227
+ }
228
+ debugLog(`[Reconcile] Ledger sync for "${project}": ${remoteLedger.length} remote, ` +
229
+ `${existingIds.size} already local, ${totalSynced} new`);
230
+ }
231
+ catch (err) {
232
+ debugLog(`[Reconcile] Ledger sync failed for "${project}" (non-fatal): ` +
233
+ `${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ }
236
+ return totalSynced;
237
+ }
@@ -1049,6 +1049,24 @@ export class SqliteStorage {
1049
1049
  });
1050
1050
  debugLog(`[SqliteStorage] Hard-deleted ledger entry ${id}`);
1051
1051
  }
1052
+ // ─── v9.2.4: Cross-Backend Reconciliation Helper ───────────
1053
+ //
1054
+ // Returns a Map of "project::role" β†’ "updated_at" for all local handoffs.
1055
+ // Used by reconcileHandoffs() for lightweight timestamp comparison.
1056
+ // Single SELECT on session_handoffs β€” no joins, no ledger access.
1057
+ async getHandoffTimestamps() {
1058
+ const result = await this.db.execute(`SELECT project, role, updated_at FROM session_handoffs`);
1059
+ const map = new Map();
1060
+ for (const row of result.rows) {
1061
+ const project = row.project;
1062
+ const role = row.role || "global";
1063
+ const updatedAt = row.updated_at;
1064
+ if (updatedAt) {
1065
+ map.set(`${project}::${role}`, updatedAt);
1066
+ }
1067
+ }
1068
+ return map;
1069
+ }
1052
1070
  // ─── Handoff Operations (OCC) ──────────────────────────────
1053
1071
  async saveHandoff(handoff, expectedVersion) {
1054
1072
  const role = handoff.role || "global"; // v3.0: default to 'global'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "9.2.3",
3
+ "version": "9.2.5",
4
4
  "mcpName": "io.github.dcostenco/prism-mcp",
5
5
  "description": "The Mind Palace for AI Agents β€” a true Cognitive Architecture with Hebbian learning (episodicβ†’semantic consolidation), ACT-R spreading activation (multi-hop causal reasoning), uncertainty-aware rejection gates (agents that know when they don't know), adversarial evaluation (anti-sycophancy), fail-closed Dark Factory pipelines, persistent memory (SQLite/Supabase), multi-agent Hivemind, time travel & visual dashboard. Zero-config local mode.",
6
6
  "module": "index.ts",