prism-mcp-server 5.2.0 → 5.5.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.
@@ -112,12 +112,13 @@ export async function agentHeartbeatHandler(args) {
112
112
  }
113
113
  const effectiveRole = args.role || await getSetting("default_role", "global");
114
114
  const storage = await getStorage();
115
- await storage.heartbeatAgent(args.project, PRISM_USER_ID, effectiveRole, args.current_task);
115
+ await storage.heartbeatAgent(args.project, PRISM_USER_ID, effectiveRole, args.current_task, args.expected_duration_minutes);
116
116
  return {
117
117
  content: [{
118
118
  type: "text",
119
119
  text: `💓 Heartbeat updated for **${escapeMd(effectiveRole)}** on \`${escapeMd(args.project)}\`.` +
120
- (args.current_task ? ` Task: ${escapeMd(args.current_task)}` : ""),
120
+ (args.current_task ? ` Task: ${escapeMd(args.current_task)}` : "") +
121
+ (args.expected_duration_minutes ? ` (ETA: ${args.expected_duration_minutes}m)` : ""),
121
122
  }],
122
123
  };
123
124
  }
@@ -138,25 +139,42 @@ export async function agentListTeamHandler(args) {
138
139
  }],
139
140
  };
140
141
  }
142
+ // v5.3: Health-state indicator mapping
143
+ const statusIndicators = {
144
+ active: "🟢 Active",
145
+ idle: "💤 Idle",
146
+ shutdown: "⚫ Offline",
147
+ stale: "🟡 Stale",
148
+ frozen: "🔴 Frozen",
149
+ overdue: "⏰ Overdue",
150
+ looping: "🔄 Looping",
151
+ };
141
152
  const lines = team.map(agent => {
142
153
  const icon = getRoleIcon(agent.role);
143
154
  const ago = agent.last_heartbeat
144
155
  ? getTimeAgo(agent.last_heartbeat)
145
156
  : "unknown";
157
+ const healthIndicator = statusIndicators[agent.status] || `❓ ${agent.status}`;
146
158
  // escapeMd() applied to all user-controlled fields to prevent
147
159
  // markdown metacharacters in task descriptions from corrupting output
148
160
  return (`${icon} **${escapeMd(agent.role)}**` +
149
161
  (agent.agent_name ? ` (${escapeMd(agent.agent_name)})` : "") +
150
- ` — ${escapeMd(agent.status)}` +
162
+ ` — ${healthIndicator}` +
151
163
  (agent.current_task ? ` | Task: ${escapeMd(agent.current_task)}` : "") +
164
+ (agent.loop_count && agent.loop_count >= 3 ? ` | 🔄 Loop: ${agent.loop_count}x` : "") +
152
165
  ` | Last seen: ${ago}`);
153
166
  });
167
+ // Count agents by health state for summary
168
+ const healthyCt = team.filter(a => a.status === "active" || a.status === "idle").length;
169
+ const warnCt = team.filter(a => ["stale", "overdue", "looping", "frozen"].includes(a.status)).length;
154
170
  return {
155
171
  content: [{
156
172
  type: "text",
157
- text: `## 🐝 Active Hivemind Team — \`${escapeMd(args.project)}\`\n\n` +
173
+ text: `## 🐝 Hivemind Team — \`${escapeMd(args.project)}\`\n\n` +
158
174
  lines.join("\n") +
159
- `\n\n_${team.length} agent(s) active. Stale agents (>30min) auto-pruned._`,
175
+ `\n\n_${team.length} agent(s) | ${healthyCt} healthy` +
176
+ (warnCt > 0 ? ` | ⚠️ ${warnCt} need attention` : "") +
177
+ `_\n_Watchdog monitoring active: health checked every 60s._`,
160
178
  }],
161
179
  };
162
180
  }
@@ -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, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, isDeepStoragePurgeArgs } from "./sessionMemoryDefinitions.js";
30
- export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler, knowledgeSetRetentionHandler, sessionSaveExperienceHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeSyncRulesHandler, sessionExportMemoryHandler, deepStoragePurgeHandler } 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, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, isDeepStoragePurgeArgs } from "./sessionMemoryDefinitions.js";
30
+ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, knowledgeSearchHandler, knowledgeForgetHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionSaveImageHandler, sessionViewImageHandler, sessionHealthCheckHandler, sessionForgetMemoryHandler, knowledgeSetRetentionHandler, sessionSaveExperienceHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeSyncRulesHandler, sessionExportMemoryHandler, deepStoragePurgeHandler, sessionIntuitiveRecallHandler } 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
@@ -55,6 +55,9 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
55
55
  description: "Upsert the latest project handoff state for the next session to consume on boot. " +
56
56
  "This is the 'live context' that gets loaded when a new session starts. " +
57
57
  "Calling this replaces the previous handoff for the same project (upsert on project).\n\n" +
58
+ "**v5.4 CRDT Merge**: On version conflict, a CRDT OR-Map engine automatically merges " +
59
+ "your changes with concurrent work (Add-Wins OR-Set for arrays, Last-Writer-Wins for scalars). " +
60
+ "Pass expected_version to enable concurrency control.\n\n" +
58
61
  "**v0.4.0 OCC**: If you received a version number from session_load_context, " +
59
62
  "/resume_session prompt, or memory resource attachment, you MUST pass it as " +
60
63
  "expected_version to prevent overwriting another session's changes.",
@@ -92,6 +95,10 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
92
95
  type: "string",
93
96
  description: "Optional. Agent role for Hivemind scoping (e.g., 'dev', 'qa', 'pm'). Omit to let the server auto-resolve from dashboard settings.",
94
97
  },
98
+ disable_merge: {
99
+ type: "boolean",
100
+ description: "Set to true to disable automatic CRDT merging and fail strictly on version conflict (original OCC behavior). Default: false.",
101
+ },
95
102
  },
96
103
  required: ["project"],
97
104
  },
@@ -369,6 +376,7 @@ export function isSessionSaveLedgerArgs(args) {
369
376
  }
370
377
  // REVIEWER NOTE: v0.4.0 adds expected_version to the type guard
371
378
  // for optimistic concurrency control. It's optional for backward compat.
379
+ // v5.4: Added disable_merge for CRDT bypass.
372
380
  export function isSessionSaveHandoffArgs(args) {
373
381
  return (typeof args === "object" &&
374
382
  args !== null &&
@@ -902,7 +910,44 @@ export function isDeepStoragePurgeArgs(args) {
902
910
  return false;
903
911
  if (a.older_than_days !== undefined && typeof a.older_than_days !== "number")
904
912
  return false;
905
- if (a.dry_run !== undefined && typeof a.dry_run !== "boolean")
913
+ return true;
914
+ }
915
+ // ─── v5.5: SDM Intuitive Recall Tool ──────────────────────────
916
+ export const SESSION_INTUITIVE_RECALL_TOOL = {
917
+ name: "session_intuitive_recall",
918
+ description: "Manually trigger the Sparse Distributed Memory (SDM) Intuitive Recall to surface latent patterns " +
919
+ "and related memories for a given query without blowing up the context window. " +
920
+ "Uses high-speed JS-space Hamming distance scanning on compressed embeddings.",
921
+ inputSchema: {
922
+ type: "object",
923
+ properties: {
924
+ project: {
925
+ type: "string",
926
+ description: "Project identifier.",
927
+ },
928
+ query: {
929
+ type: "string",
930
+ description: "The text query or context to trigger the recall.",
931
+ },
932
+ limit: {
933
+ type: "integer",
934
+ description: "Maximum number of latent patterns to surface (default: 3).",
935
+ },
936
+ threshold: {
937
+ type: "number",
938
+ description: "Similarity threshold 0-1 (default: 0.55).",
939
+ },
940
+ },
941
+ required: ["project", "query"],
942
+ },
943
+ };
944
+ export function isSessionIntuitiveRecallArgs(args) {
945
+ if (typeof args !== "object" || args === null)
946
+ return false;
947
+ const a = args;
948
+ if (typeof a.project !== "string")
949
+ return false;
950
+ if (typeof a.query !== "string")
906
951
  return false;
907
952
  return true;
908
953
  }
@@ -21,6 +21,7 @@ import { toKeywordArray } from "../utils/keywordExtractor.js";
21
21
  import { getLLMProvider } from "../utils/llm/factory.js";
22
22
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
23
23
  import { getSetting, getAllSettings } from "../storage/configStorage.js";
24
+ import { mergeHandoff, dbToHandoffSchema } from "../utils/crdtMerge.js";
24
25
  // ─── Phase 1: Explainability & Memory Lineage ────────────────
25
26
  // These utilities provide structured tracing metadata for search operations.
26
27
  // When `enable_trace: true` is passed to session_search_memory or knowledge_search,
@@ -39,11 +40,13 @@ isSessionSaveExperienceArgs, isKnowledgeVoteArgs,
39
40
  // v4.2: Sync Rules type guard
40
41
  isKnowledgeSyncRulesArgs,
41
42
  // v5.1: Deep Storage Mode type guard
42
- isDeepStoragePurgeArgs, } from "./sessionMemoryDefinitions.js";
43
+ isDeepStoragePurgeArgs,
44
+ // v5.5: SDM Intuitive Recall type guard
45
+ isSessionIntuitiveRecallArgs, } from "./sessionMemoryDefinitions.js";
43
46
  // v4.2: File system access for knowledge_sync_rules
44
47
  import { readFile, writeFile, mkdir } from "node:fs/promises";
45
48
  import { existsSync } from "node:fs";
46
- import { join, dirname, resolve, isAbsolute, sep } from "node:path";
49
+ import { join, dirname, resolve, isAbsolute, relative } from "node:path";
47
50
  // v3.1: In-memory debounce lock for auto-compaction.
48
51
  // Prevents multiple concurrent Gemini compaction tasks for the same project
49
52
  // when many agents call session_save_ledger at the same time.
@@ -201,7 +204,7 @@ export async function sessionSaveHandoffHandler(args, server) {
201
204
  `(expected_version=${expected_version ?? "none"})`);
202
205
  // Auto-extract keywords from summary + context for knowledge accumulation
203
206
  const combinedText = [last_summary || "", key_context || ""].filter(Boolean).join(" ");
204
- const keywords = combinedText ? toKeywordArray(combinedText) : undefined;
207
+ let keywords = combinedText ? toKeywordArray(combinedText) : undefined;
205
208
  if (keywords) {
206
209
  debugLog(`[session_save_handoff] Extracted ${keywords.length} keywords: ${keywords.slice(0, 5).join(", ")}...`);
207
210
  }
@@ -215,7 +218,7 @@ export async function sessionSaveHandoffHandler(args, server) {
215
218
  }
216
219
  // Save via storage backend (OCC-aware)
217
220
  const effectiveRole = role || await getSetting("default_role", "global");
218
- const data = await storage.saveHandoff({
221
+ let data = await storage.saveHandoff({
219
222
  project,
220
223
  user_id: PRISM_USER_ID,
221
224
  last_summary: last_summary ?? null,
@@ -227,32 +230,99 @@ export async function sessionSaveHandoffHandler(args, server) {
227
230
  metadata,
228
231
  role: effectiveRole, // v3.0: Hivemind role scoping (dashboard fallback)
229
232
  }, expected_version ?? null);
230
- // ─── Handle version conflict ───
233
+ // ─── v5.4: CRDT Auto-Merge Resolution Loop ──────────────────
234
+ //
235
+ // Instead of returning a conflict error, we now:
236
+ // 1. Fetch the base state (the version the incoming agent read)
237
+ // 2. Fetch the current DB state (what beat the incoming agent)
238
+ // 3. Run a 3-way CRDT merge (OR-Set for arrays, LWW for scalars)
239
+ // 4. Retry the save with the merged state
240
+ //
241
+ // This converts what was previously an error into an automatic merge.
242
+ // The loop handles the rare case where ANOTHER save sneaks in during
243
+ // our merge (up to MAX_ATTEMPTS retries before giving up).
244
+ const MAX_MERGE_ATTEMPTS = 3;
245
+ let mergeAttempts = 0;
246
+ let isMerged = false;
247
+ let mergeStrategy = null;
248
+ while (data.status === "conflict" && mergeAttempts < MAX_MERGE_ATTEMPTS) {
249
+ // If the user explicitly disabled CRDT merging, return old OCC error
250
+ if (args.disable_merge) {
251
+ debugLog(`[session_save_handoff] VERSION CONFLICT for "${project}": ` +
252
+ `expected=${expected_version}, current=${data.current_version} (merge disabled)`);
253
+ return {
254
+ content: [{
255
+ type: "text",
256
+ text: `⚠️ Version conflict detected for project "${project}"!\n\n` +
257
+ `You sent version ${expected_version}, but the current version is ${data.current_version}.\n` +
258
+ `Auto-merge is disabled. Please call session_load_context to see the latest changes, ` +
259
+ `then manually merge your updates and try saving again.`,
260
+ }],
261
+ isError: true,
262
+ };
263
+ }
264
+ debugLog(`[session_save_handoff] CRDT merge attempt ${mergeAttempts + 1}/${MAX_MERGE_ATTEMPTS} ` +
265
+ `for "${project}" (expected=${expected_version}, current=${data.current_version})`);
266
+ // Step 1: Fetch the base state (what the incoming agent originally read)
267
+ const baseDbState = expected_version
268
+ ? await storage.getHandoffAtVersion(project, expected_version, PRISM_USER_ID)
269
+ : null;
270
+ const baseState = dbToHandoffSchema(baseDbState);
271
+ // Step 2: Fetch current DB state (what beat us to the save)
272
+ const currentDbState = await storage.loadContext(project, "standard", PRISM_USER_ID);
273
+ const currentState = dbToHandoffSchema(currentDbState);
274
+ if (!currentState || !currentDbState) {
275
+ debugLog("[session_save_handoff] CRDT merge failed: could not load current state");
276
+ break; // Safety fallback — can't merge without both sides
277
+ }
278
+ // Step 3: Build the incoming state from the original args
279
+ const incomingState = {
280
+ summary: last_summary || "",
281
+ active_branch: active_branch,
282
+ key_context: key_context,
283
+ pending_todo: open_todos,
284
+ active_decisions: undefined,
285
+ keywords: keywords,
286
+ };
287
+ // Step 4: Run 3-way CRDT merge
288
+ const crdt = mergeHandoff(baseState, incomingState, currentState);
289
+ mergeStrategy = crdt.strategy;
290
+ isMerged = true;
291
+ debugLog(`[session_save_handoff] CRDT merge strategy: ${JSON.stringify(crdt.strategy)}`);
292
+ // Step 5: Build merged handoff and retry save
293
+ const mergedExpectedVersion = currentDbState.version;
294
+ data = await storage.saveHandoff({
295
+ project,
296
+ user_id: PRISM_USER_ID,
297
+ last_summary: crdt.merged.summary ?? null,
298
+ pending_todo: crdt.merged.pending_todo ?? null,
299
+ active_decisions: crdt.merged.active_decisions ?? null,
300
+ keywords: crdt.merged.keywords ?? null,
301
+ key_context: crdt.merged.key_context ?? null,
302
+ active_branch: crdt.merged.active_branch ?? null,
303
+ metadata: {
304
+ ...metadata,
305
+ crdt_merge_count: (currentDbState.metadata?.crdt_merge_count || 0) + 1,
306
+ last_merge_strategy: crdt.strategy,
307
+ },
308
+ role: effectiveRole,
309
+ }, mergedExpectedVersion ?? null);
310
+ // Update these for the snapshot/notification blocks below
311
+ if (data.status !== "conflict") {
312
+ // Merge succeeded — update local vars for the success path
313
+ keywords = crdt.merged.keywords ?? keywords;
314
+ }
315
+ mergeAttempts++;
316
+ }
317
+ // After all merge attempts exhausted, still a conflict → give up
231
318
  if (data.status === "conflict") {
232
- debugLog(`[session_save_handoff] VERSION CONFLICT for "${project}": ` +
233
- `expected=${expected_version}, current=${data.current_version}`);
319
+ debugLog(`[session_save_handoff] CRDT merge exhausted after ${MAX_MERGE_ATTEMPTS} attempts for "${project}"`);
234
320
  return {
235
321
  content: [{
236
322
  type: "text",
237
- text: `⚠️ Version conflict detected for project "${project}"!\n\n` +
238
- `You sent version ${expected_version}, but the current version is ${data.current_version}.\n` +
239
- `Another session has updated this project since you loaded context.\n\n` +
240
- `Please call session_load_context to see what changed, then merge ` +
241
- `it with your attempted updates:\n` +
242
- (last_summary
243
- ? ` Your attempted summary: ${last_summary}\n`
244
- : "") +
245
- (open_todos?.length
246
- ? ` Your attempted TODOs: ${JSON.stringify(open_todos)}\n`
247
- : "") +
248
- (key_context
249
- ? ` Your attempted key_context: ${key_context}\n`
250
- : "") +
251
- (active_branch
252
- ? ` Your attempted active_branch: ${active_branch}\n`
253
- : "") +
254
- `\nAfter reviewing the latest state, call session_save_handoff again ` +
255
- `with the updated expected_version.`,
323
+ text: `⚠️ CRDT auto-merge failed for "${project}" after ${MAX_MERGE_ATTEMPTS} attempts ` +
324
+ `due to high contention. Please run session_load_context to see the latest state ` +
325
+ `and try saving again.`,
256
326
  }],
257
327
  isError: true,
258
328
  };
@@ -388,16 +458,24 @@ export async function sessionSaveHandoffHandler(args, server) {
388
458
  // Dynamic import itself failed — module not found or similar
389
459
  console.error("[FactMerger] Module load failed (non-fatal): " + err));
390
460
  }
461
+ // Build response text based on whether a CRDT merge occurred
462
+ const responseText = isMerged
463
+ ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
464
+ `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
465
+ (last_summary ? `Summary: ${last_summary}\n` : "") +
466
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
467
+ `to maintain concurrency control.`
468
+ : `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
469
+ `(version: ${newVersion})\n` +
470
+ (last_summary ? `Last summary: ${last_summary}\n` : "") +
471
+ (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
472
+ (active_branch ? `Active branch: ${active_branch}\n` : "") +
473
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
474
+ `to maintain concurrency control.`;
391
475
  return {
392
476
  content: [{
393
477
  type: "text",
394
- text: `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
395
- `(version: ${newVersion})\n` +
396
- (last_summary ? `Last summary: ${last_summary}\n` : "") +
397
- (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
398
- (active_branch ? `Active branch: ${active_branch}\n` : "") +
399
- `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
400
- `to maintain concurrency control.`,
478
+ text: responseText,
401
479
  }],
402
480
  isError: false,
403
481
  };
@@ -587,8 +665,36 @@ export async function sessionLoadContextHandler(args) {
587
665
  const skillPart = skillLoaded ? ` · 📜 \`${effectiveRole}\` skill loaded` : (effectiveRole ? " · 📜 No skill configured" : "");
588
666
  greetingBlock = `\n\n[👤 AGENT IDENTITY]\n${namePart}${rolePart}${skillPart}`;
589
667
  }
668
+ // ─── SDM Intuitive Recall (v5.5) ───
669
+ // Generate embedding of current context and fetch latent SDM patterns
670
+ let sdmRecallBlock = "";
671
+ if (level !== "quick" && GOOGLE_API_KEY) {
672
+ try {
673
+ const activeText = [d.last_summary, d.key_context, ...(d.keywords || [])].filter(Boolean).join(" ");
674
+ if (activeText.length > 10) {
675
+ // v2.1 LLM factory handles the API call
676
+ const queryVector = await getLLMProvider().generateEmbedding(activeText);
677
+ // Lazy-load to avoid blocking server boot
678
+ const { getSdmEngine } = await import("../sdm/sdmEngine.js");
679
+ const { decodeSdmVector } = await import("../sdm/sdmDecoder.js");
680
+ const sdmEngine = getSdmEngine(project);
681
+ const targetVector = sdmEngine.read(new Float32Array(queryVector));
682
+ const topMatches = await decodeSdmVector(project, targetVector, 3, 0.55);
683
+ if (topMatches.length > 0) {
684
+ sdmRecallBlock = `\n\n[🧠 INTUITIVE RECALL]\nThe deeper Superposed Memory matrix resonated with your current task and surfaced these latent patterns:\n`;
685
+ for (const match of topMatches) {
686
+ sdmRecallBlock += `- [Sim: ${(match.similarity * 100).toFixed(1)}%] ${match.summary}\n`;
687
+ }
688
+ debugLog(`[session_load_context] SDM Recall surfaced ${topMatches.length} latent patterns`);
689
+ }
690
+ }
691
+ }
692
+ catch (err) {
693
+ debugLog(`[session_load_context] SDM Recall failed (non-fatal): ${err instanceof Error ? err.message : err}`);
694
+ }
695
+ }
590
696
  // Build the response object before v4.0 augmentations
591
- let responseText = `📋 Session context for "${project}" (${level}):\n\n${formattedContext.trim()}${driftReport}${briefingBlock}${greetingBlock}${visualMemoryBlock}${skillBlock}${versionNote}`;
697
+ let responseText = `📋 Session context for "${project}" (${level}):\n\n${formattedContext.trim()}${driftReport}${briefingBlock}${sdmRecallBlock}${greetingBlock}${visualMemoryBlock}${skillBlock}${versionNote}`;
592
698
  // ─── v4.0: Behavioral Warnings Injection ───────────────────
593
699
  // If loadContext returned behavioral_warnings, add them to the
594
700
  // formatted output so the agent sees them prominently.
@@ -1059,10 +1165,13 @@ export async function backfillEmbeddingsHandler(args) {
1059
1165
  "embedding": "is.null",
1060
1166
  "archived_at": "is.null",
1061
1167
  user_id: `eq.${PRISM_USER_ID}`,
1062
- order: "created_at.desc",
1168
+ order: "id.asc",
1063
1169
  limit: String(safeLimit),
1064
1170
  select: "id,summary,decisions,project",
1065
1171
  };
1172
+ if (args._cursor_id) {
1173
+ params.id = `gt.${args._cursor_id}`;
1174
+ }
1066
1175
  if (project) {
1067
1176
  params.project = `eq.${project}`;
1068
1177
  }
@@ -1143,6 +1252,7 @@ export async function backfillEmbeddingsHandler(args) {
1143
1252
  : `All entries now have embeddings for semantic search.`),
1144
1253
  }],
1145
1254
  isError: false,
1255
+ _stats: { repaired, failed, last_id: entries[entries.length - 1]?.id },
1146
1256
  };
1147
1257
  }
1148
1258
  // ─── Memory History Handler (v2.0 — Time Travel) ─────────────
@@ -1482,8 +1592,28 @@ export async function sessionHealthCheckHandler(args) {
1482
1592
  if (embeddingIssue && embeddingIssue.count > 0) {
1483
1593
  debugLog("[Health Check] Auto-fixing " + embeddingIssue.count + " missing embeddings...");
1484
1594
  try {
1485
- await backfillEmbeddingsHandler({ dry_run: false, limit: 50 });
1486
- fixedCount += embeddingIssue.count;
1595
+ let hasMore = true;
1596
+ let cursorId = undefined;
1597
+ while (hasMore) {
1598
+ const result = await backfillEmbeddingsHandler({ dry_run: false, limit: 50, _cursor_id: cursorId });
1599
+ const stats = result._stats;
1600
+ if (stats) {
1601
+ fixedCount += stats.repaired;
1602
+ if (stats.last_id) {
1603
+ cursorId = stats.last_id;
1604
+ }
1605
+ else {
1606
+ hasMore = false;
1607
+ }
1608
+ // If we repaired + failed less than 50, we're done
1609
+ if ((stats.repaired + stats.failed) < 50) {
1610
+ hasMore = false;
1611
+ }
1612
+ }
1613
+ else {
1614
+ hasMore = false; // Fallback if no stats returned
1615
+ }
1616
+ }
1487
1617
  debugLog("[Health Check] Backfill complete.");
1488
1618
  }
1489
1619
  catch (err) {
@@ -1940,9 +2070,10 @@ export async function knowledgeSyncRulesHandler(args) {
1940
2070
  // Resolve both paths to their canonical forms, then assert containment
1941
2071
  const resolvedRepo = resolve(normalizedRepoPath);
1942
2072
  const targetPath = resolve(resolvedRepo, target_file);
2073
+ const relativePath = relative(resolvedRepo, targetPath);
1943
2074
  // Ensure the resolved target is strictly inside the repo root
1944
2075
  // (handles "../../../etc/hosts" style traversal)
1945
- if (!targetPath.startsWith(resolvedRepo + sep)) {
2076
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
1946
2077
  return {
1947
2078
  content: [{
1948
2079
  type: "text",
@@ -2224,3 +2355,44 @@ export async function deepStoragePurgeHandler(args) {
2224
2355
  isError: false,
2225
2356
  };
2226
2357
  }
2358
+ // ─── v5.5: SDM Intuitive Recall Handler ───────────────────────
2359
+ export async function sessionIntuitiveRecallHandler(args) {
2360
+ if (!isSessionIntuitiveRecallArgs(args)) {
2361
+ return {
2362
+ content: [{ type: "text", text: "Invalid arguments for session_intuitive_recall" }],
2363
+ isError: true,
2364
+ };
2365
+ }
2366
+ try {
2367
+ const { getSdmEngine } = await import("../sdm/sdmEngine.js");
2368
+ const { decodeSdmVector } = await import("../sdm/sdmDecoder.js");
2369
+ const queryVector = await getLLMProvider().generateEmbedding(args.query);
2370
+ const sdmEngine = getSdmEngine(args.project);
2371
+ const targetVector = sdmEngine.read(new Float32Array(queryVector));
2372
+ const limit = args.limit ?? 3;
2373
+ const threshold = args.threshold ?? 0.55;
2374
+ const topMatches = await decodeSdmVector(args.project, targetVector, limit, threshold);
2375
+ let recallBlock = `🧠 **SDM Intuitive Recall for "${args.project}"**\n\n`;
2376
+ recallBlock += `Query: "${args.query}"\n`;
2377
+ recallBlock += `Target vector generated. Scanning ${topMatches.length > 0 ? topMatches.length + " latents surfaced." : "No strong patterns surfaced."}\n\n`;
2378
+ if (topMatches.length === 0) {
2379
+ recallBlock += `*No stored patterns resonated above the ${(threshold * 100).toFixed(1)}% similarity threshold.*`;
2380
+ }
2381
+ else {
2382
+ for (const match of topMatches) {
2383
+ recallBlock += `- [Similarity: ${(match.similarity * 100).toFixed(1)}%] ${match.summary}\n`;
2384
+ }
2385
+ }
2386
+ return {
2387
+ content: [{ type: "text", text: recallBlock }],
2388
+ isError: false,
2389
+ };
2390
+ }
2391
+ catch (err) {
2392
+ debugLog(`[session_intuitive_recall] Failed: ${err}`);
2393
+ return {
2394
+ content: [{ type: "text", text: `Error triggering Intuitive Recall: ${err instanceof Error ? err.message : String(err)}` }],
2395
+ isError: true,
2396
+ };
2397
+ }
2398
+ }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * ═══════════════════════════════════════════════════════════════════
8
8
  * DESIGN DECISIONS:
9
- * - Uses gemini-2.0-flash for max speed (~2-3s generation)
9
+ * - Uses gemini-2.5-flash for max speed (~2-3s generation)
10
10
  * - Graceful fallback if no API key or Gemini call fails
11
11
  * - Prompt is tuned for brevity — exactly 3 bullets, no fluff
12
12
  * - Reuses GOOGLE_API_KEY from config.ts (same key as embeddings)
@@ -0,0 +1,152 @@
1
+ /**
2
+ * CRDT Handoff Merge Engine (v5.4 Phase 2)
3
+ *
4
+ * Provides a zero-dependency, in-memory OR-Map implementation for resolving
5
+ * concurrent agent state changes deterministically.
6
+ *
7
+ * Design:
8
+ * - Arrays (TODOs, decisions, keywords) use Add-Wins OR-Set semantics.
9
+ * - Tombstones are ephemeral: calculated via 3-way diff against the base state.
10
+ * - Scalars (summary, context) use Last-Writer-Wins (LWW) Register semantics.
11
+ *
12
+ * ═══════════════════════════════════════════════════════════════════
13
+ * REVIEWER NOTE (v5.4):
14
+ *
15
+ * WHY BESPOKE, NOT YJS/AUTOMERGE?
16
+ * - Our merge surface is tiny: 6 fields (3 arrays, 2 scalars, 1 optional map).
17
+ * - Yjs adds ~50-200KB to the bundle and requires a stateful document model.
18
+ * - Automerge's WASM runtime would break MCP cold-start (<1s requirement).
19
+ * - This module is ~100 LOC, zero deps, and fully deterministic.
20
+ *
21
+ * TOMBSTONE STRATEGY:
22
+ * Tombstones are computed in-memory by diffing the `base` state against each
23
+ * agent's submission. They exist only for the duration of the merge operation —
24
+ * no database columns, no growing tombstone tables, no cleanup cron jobs.
25
+ * This works because handoff state is a live document (upserted, not appended).
26
+ * ═══════════════════════════════════════════════════════════════════
27
+ */
28
+ // ─── OR-Set Logic (Add-Wins) ────────────────────────────────────
29
+ //
30
+ // 3-way set merge:
31
+ // added_by_incoming = incoming - base
32
+ // removed_by_incoming = base - incoming
33
+ // added_by_current = current - base
34
+ // removed_by_current = base - current
35
+ // result = (base - removals) ∪ all_adds
36
+ //
37
+ // "Add-Wins" means: if Agent A removes "X" and Agent B adds "X",
38
+ // the add wins. This is safe for TODOs (better to have a duplicate
39
+ // than lose work) and keywords (idempotent).
40
+ function mergeArray(b = [], i = [], c = []) {
41
+ const bSet = new Set(b);
42
+ const iSet = new Set(i);
43
+ const cSet = new Set(c);
44
+ const removals = new Set();
45
+ const adds = new Set();
46
+ // Items explicitly removed by either agent
47
+ for (const item of bSet) {
48
+ if (!iSet.has(item))
49
+ removals.add(item);
50
+ if (!cSet.has(item))
51
+ removals.add(item);
52
+ }
53
+ // Items freshly added by either agent
54
+ for (const item of iSet)
55
+ if (!bSet.has(item))
56
+ adds.add(item);
57
+ for (const item of cSet)
58
+ if (!bSet.has(item))
59
+ adds.add(item);
60
+ // Final state: (Base - Removals) ∪ Adds
61
+ const result = new Set();
62
+ for (const item of bSet)
63
+ if (!removals.has(item))
64
+ result.add(item);
65
+ for (const item of adds)
66
+ result.add(item);
67
+ return Array.from(result);
68
+ }
69
+ // ─── LWW Register Logic ────────────────────────────────────────
70
+ //
71
+ // If the incoming agent changed a scalar, its value wins (latest intent).
72
+ // If the incoming agent left it untouched, the current DB value wins.
73
+ // If both changed it, incoming wins (the caller is the latest writer).
74
+ function mergeScalar(b, i, c) {
75
+ const incomingChanged = i !== b;
76
+ const currentChanged = c !== b;
77
+ if (!incomingChanged && !currentChanged) {
78
+ return { value: c, winner: "no-change" };
79
+ }
80
+ if (incomingChanged) {
81
+ return { value: i, winner: "lww-incoming" };
82
+ }
83
+ return { value: c, winner: "lww-current" };
84
+ }
85
+ // ─── Main Merge Function ────────────────────────────────────────
86
+ /**
87
+ * 3-Way CRDT Merge for Handoff State.
88
+ *
89
+ * @param base The state both agents read (at the conflicting version).
90
+ * If null, treated as an empty state (first handoff).
91
+ * @param incoming The state submitted by the agent that lost the OCC race.
92
+ * @param current The state currently in the DB (the OCC winner).
93
+ * @returns The conflict-free merged state + per-field audit trail.
94
+ */
95
+ export function mergeHandoff(base, incoming, current) {
96
+ const safeBase = base || { summary: "" };
97
+ const strategy = {};
98
+ // ─── Scalars (LWW) ───
99
+ const summaryMerge = mergeScalar(safeBase.summary, incoming.summary, current.summary);
100
+ const branchMerge = mergeScalar(safeBase.active_branch, incoming.active_branch, current.active_branch);
101
+ const contextMerge = mergeScalar(safeBase.key_context, incoming.key_context, current.key_context);
102
+ strategy.summary = summaryMerge.winner;
103
+ strategy.active_branch = branchMerge.winner;
104
+ strategy.key_context = contextMerge.winner;
105
+ // ─── Arrays (OR-Set) ───
106
+ const mergedTodos = mergeArray(safeBase.pending_todo || [], incoming.pending_todo || [], current.pending_todo || []);
107
+ const mergedDecisions = mergeArray(safeBase.active_decisions || [], incoming.active_decisions || [], current.active_decisions || []);
108
+ const mergedKeywords = mergeArray(safeBase.keywords || [], incoming.keywords || [], current.keywords || []);
109
+ strategy.pending_todo = "or-set-union";
110
+ strategy.active_decisions = "or-set-union";
111
+ strategy.keywords = "or-set-union";
112
+ const merged = {
113
+ summary: summaryMerge.value || current.summary || "",
114
+ active_branch: branchMerge.value,
115
+ key_context: contextMerge.value,
116
+ pending_todo: mergedTodos,
117
+ active_decisions: mergedDecisions,
118
+ keywords: mergedKeywords,
119
+ };
120
+ return { merged, strategy };
121
+ }
122
+ // ─── Adapter: DB Record → HandoffSchema ─────────────────────────
123
+ //
124
+ // Extracts the merge-relevant fields from either a loadContext result
125
+ // or a history snapshot. Tolerant of both {last_summary} and {summary}
126
+ // naming conventions (handler vs. DB column inconsistency).
127
+ export function dbToHandoffSchema(dbState) {
128
+ if (!dbState)
129
+ return null;
130
+ const toStringArray = (v) => {
131
+ if (Array.isArray(v))
132
+ return v;
133
+ if (typeof v === "string") {
134
+ try {
135
+ const parsed = JSON.parse(v);
136
+ return Array.isArray(parsed) ? parsed : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ return null;
143
+ };
144
+ return {
145
+ summary: dbState.last_summary || dbState.summary || "",
146
+ active_branch: dbState.active_branch ?? null,
147
+ key_context: dbState.key_context ?? null,
148
+ pending_todo: toStringArray(dbState.pending_todo),
149
+ active_decisions: toStringArray(dbState.active_decisions),
150
+ keywords: toStringArray(dbState.keywords),
151
+ };
152
+ }