prism-mcp-server 20.2.9 → 20.3.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.
package/README.md CHANGED
@@ -77,6 +77,21 @@ to the patched 8.5.23 release.
77
77
 
78
78
  ---
79
79
 
80
+ ## What's New in v20.3.0
81
+
82
+ ### Hybrid Memory Search (Portal Tier)
83
+
84
+ `session_search_memory` on Synalux-backed installs now fuses semantic
85
+ similarity with exact-term lexical matching (weighted reciprocal-rank
86
+ fusion). On blind probes against a real 8.5k-entry corpus this lifted
87
+ top-1 retrieval from 45% (semantic alone) to 59%; exact identifiers such
88
+ as TPNs, function names and error strings now rescue queries that
89
+ embeddings blur. Results say how they were found — `hybrid retrieval`
90
+ headers, per-hit `sem#/lex#` arms — and a lexical-only rescue is labelled
91
+ `exact-term match` instead of pretending to a similarity score. Local
92
+ SQLite installs keep pure vector search; hybrid needs the portal's
93
+ lexical index.
94
+
80
95
  ## What's New in v20.2.6
81
96
 
82
97
  ### Safer Configuration Updates Across Every Agent
@@ -39,4 +39,8 @@ export const KnowledgeSearchResponseSchema = z.object({
39
39
  action: z.literal("knowledge_search"),
40
40
  count: z.number(),
41
41
  results: z.array(z.record(z.string(), z.unknown())),
42
+ /** How the portal matched. Optional so an older portal deployment that
43
+ * predates the ranked search RPC still validates. When present and
44
+ * 'relaxed', callers MUST NOT present the rows as exact hits. */
45
+ match_mode: z.enum(["strict", "relaxed", "unfiltered", "none"]).optional(),
42
46
  });
@@ -25,17 +25,30 @@
25
25
  * - searchKnowledge → POST /api/v1/prism/memory action=search
26
26
  * - softDeleteLedger → POST /api/v1/prism/memory action=forget_memory (Phase 3 Tier A)
27
27
  * - hardDeleteLedger → POST /api/v1/prism/memory action=forget_memory (Phase 3 Tier A)
28
+ * - searchMemory → POST /api/v1/prism/memory action=search_memory
29
+ * - getHistory → POST /api/v1/prism/memory action=memory_history
30
+ * - patchLedger → POST /api/v1/prism/memory action=save_embedding
31
+ * - getEntriesMissingEmbeddings → POST /api/v1/prism/memory action=list_missing_embeddings
28
32
  *
29
33
  * Methods still falling through to SupabaseStorage (Phase 3 Tier B+):
30
- * semantic searchMemory, save_experience direct entrypoint,
31
- * compactLedger, image ops, history, hivemind, etc.
34
+ * save_experience direct entrypoint, compactLedger, image ops,
35
+ * hivemind, etc. Anything in this group requires a direct SUPABASE_URL
36
+ * and therefore does NOT work on paid-tier installs — that is precisely
37
+ * how embedding writes failed silently: patchLedger was inherited, threw
38
+ * against a URL that is not configured, and the caller swallowed it.
39
+ * Before relying on an inherited method, check it is actually reachable.
40
+ *
41
+ * NOTE: this list was previously wrong — it named searchMemory and
42
+ * history as falling through when both had already been overridden.
43
+ * A stale routing map here sends the next reader down the wrong path,
44
+ * so amend it in the same commit that moves a method.
32
45
  * See portal/docs/PHASE_3_PORTAL_ENDPOINTS.md for the full catalog.
33
46
  * ═══════════════════════════════════════════════════════════════════
34
47
  */
35
48
  import { SupabaseStorage } from "./supabase.js";
36
49
  import { debugLog } from "../utils/logger.js";
37
50
  import { PRISM_SYNALUX_BASE_URL, PRISM_SYNALUX_API_KEY } from "../config.js";
38
- import { KnowledgeSearchRequestSchema } from "./portalContracts.js";
51
+ import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema } from "./portalContracts.js";
39
52
  function resolveKnowledgeScope(callerScope) {
40
53
  if (callerScope === "user" || callerScope === "workspace") {
41
54
  return callerScope;
@@ -330,6 +343,8 @@ export class SynaluxStorage extends SupabaseStorage {
330
343
  const result = await this.portalPost("/api/v1/prism/memory", {
331
344
  action: "search_memory",
332
345
  project: params.project ?? undefined,
346
+ // Enables the portal's hybrid lexical+semantic fusion (see interface).
347
+ query: params.queryText || undefined,
333
348
  query_embedding: parsed,
334
349
  similarity_threshold: params.similarityThreshold,
335
350
  limit: params.limit,
@@ -358,9 +373,81 @@ export class SynaluxStorage extends SupabaseStorage {
358
373
  scope: resolveKnowledgeScope(params.scope),
359
374
  });
360
375
  const result = await this.portalPost("/api/v1/prism/memory", wireBody);
376
+ // Validate the RESPONSE against the shared contract, not just the request.
377
+ // Before this, only the outgoing shape was checked — so a portal-side
378
+ // field rename would have gone unnoticed on both sides, which is exactly
379
+ // the 2026-05-24 class of incident this file exists to prevent.
380
+ // safeParse (not parse) on purpose: drift must be loud, but it must not
381
+ // take knowledge_search offline. The build-time contract test is what
382
+ // fails hard; at runtime we log and degrade to lenient extraction.
383
+ const validated = KnowledgeSearchResponseSchema.safeParse(result);
384
+ if (!validated.success) {
385
+ console.error("[synalux] knowledge_search response failed contract validation — " +
386
+ "portal and client may have drifted: " +
387
+ JSON.stringify(validated.error.issues.map(i => ({ path: i.path, code: i.code }))));
388
+ }
361
389
  const count = typeof result.count === "number" ? result.count : 0;
362
390
  const results = Array.isArray(result.results) ? result.results : [];
363
- return { count, results };
391
+ const matchMode = validated.success ? validated.data.match_mode : undefined;
392
+ return { count, results, match_mode: matchMode };
393
+ }
394
+ /**
395
+ * Persist embedding data for an already-saved entry.
396
+ *
397
+ * MUST be overridden here. SupabaseStorage.patchLedger writes straight to
398
+ * Supabase via supabasePatch, which needs a direct SUPABASE_URL — not
399
+ * configured for paid-tier installs. Inheriting it meant every embedding
400
+ * write threw, and session_save_ledger's fire-and-forget catch swallowed
401
+ * the error while still reporting "Embedding generation queued". The
402
+ * result was 0 of 8,560 rows carrying an embedding and semantic search
403
+ * silently returning nothing.
404
+ *
405
+ * Only the vector is sent. embedding_compressed / embedding_format /
406
+ * embedding_turbo_radius are local-SQLite columns that do not exist on the
407
+ * portal schema; forwarding them would fail the whole write for fields the
408
+ * server has nowhere to put.
409
+ */
410
+ async patchLedger(id, data) {
411
+ const raw = data.embedding;
412
+ if (raw === undefined || raw === null)
413
+ return;
414
+ // ledgerHandlers JSON-stringifies the vector before patching; accept both.
415
+ let vector = raw;
416
+ if (typeof raw === "string") {
417
+ try {
418
+ vector = JSON.parse(raw);
419
+ }
420
+ catch {
421
+ throw new Error("patchLedger: embedding string is not valid JSON");
422
+ }
423
+ }
424
+ if (!Array.isArray(vector)) {
425
+ throw new Error("patchLedger: embedding must be an array");
426
+ }
427
+ await this.portalPost("/api/v1/prism/memory", {
428
+ action: "save_embedding",
429
+ memory_id: id,
430
+ embedding: vector,
431
+ });
432
+ }
433
+ /**
434
+ * Rows semantic search cannot find, read through the portal.
435
+ *
436
+ * The backfill tool used to reach these via inherited getLedgerEntries —
437
+ * a direct Supabase read paid-tier installs cannot make (NXDOMAIN), so the
438
+ * repair tool could never see what needed repairing. The portal endpoint
439
+ * ignores cursorId (it always returns the oldest missing rows, and rows
440
+ * gain embeddings as the backfill proceeds, so the frontier advances by
441
+ * itself); it is accepted here to satisfy the shared signature.
442
+ */
443
+ async getEntriesMissingEmbeddings(params) {
444
+ const result = await this.portalPost("/api/v1/prism/memory", {
445
+ action: "list_missing_embeddings",
446
+ limit: params.limit,
447
+ ...(params.project ? { project: params.project } : {}),
448
+ });
449
+ const entries = Array.isArray(result.entries) ? result.entries : [];
450
+ return entries;
364
451
  }
365
452
  // ─── Time Travel ─────────────────────────────────────────────
366
453
  // Phase 3 Tier B: route memory_history through portal instead of
@@ -397,8 +484,15 @@ export class SynaluxStorage extends SupabaseStorage {
397
484
  const inventory = result.inventory;
398
485
  const totalActiveEntries = typeof inventory?.ledger_entries === "number" ? inventory.ledger_entries : 0;
399
486
  const totalHandoffs = typeof inventory?.active_projects === "number" ? inventory.active_projects : 0;
487
+ // Hardcoding 0 here certified a 100%-missing-embeddings outage as
488
+ // "HEALTHY — all clean". Use the portal's real count; if the portal
489
+ // predates the field, report -1 so healthCheck can say "unknown"
490
+ // instead of lying in either direction.
491
+ const missingEmbeddings = typeof inventory?.ledger_missing_embeddings === "number"
492
+ ? inventory.ledger_missing_embeddings
493
+ : -1;
400
494
  return {
401
- missingEmbeddings: 0,
495
+ missingEmbeddings,
402
496
  activeLedgerSummaries: [],
403
497
  orphanedHandoffs: [],
404
498
  staleRollups: 0,
@@ -411,7 +505,9 @@ export class SynaluxStorage extends SupabaseStorage {
411
505
  catch (e) {
412
506
  debugLog("[SynaluxStorage] getHealthStats failed: " + (e instanceof Error ? e.message : String(e)));
413
507
  return {
414
- missingEmbeddings: 0,
508
+ // Portal unreachable: coverage is UNKNOWN, not zero. -1 makes the
509
+ // health check say so rather than certify blind.
510
+ missingEmbeddings: -1,
415
511
  activeLedgerSummaries: [],
416
512
  orphanedHandoffs: [],
417
513
  staleRollups: 0,
@@ -57,6 +57,56 @@ import { ConceptDictionary } from "../sdm/conceptDictionary.js";
57
57
  import { PolicyGateway } from "../sdm/policyGateway.js";
58
58
  import { getSdmEngine } from "../sdm/sdmEngine.js";
59
59
  import { PRISM_HDC_ENABLED, PRISM_HDC_EXPLAINABILITY_ENABLED, PRISM_HDC_POLICY_FALLBACK_THRESHOLD, PRISM_HDC_POLICY_CLARIFY_THRESHOLD, } from "../config.js";
60
+ /**
61
+ * Header line for knowledge_search results.
62
+ *
63
+ * A 'relaxed' match_mode means no entry matched every query term — the portal
64
+ * widened the search and returned the closest entries. Wording those the same
65
+ * as an exact hit is how a best-effort guess gets read as a confirmed answer,
66
+ * so the distinction is stated in the text the agent actually sees.
67
+ *
68
+ * Exported so the wording is covered by a test that exercises this function
69
+ * rather than a copy of it.
70
+ */
71
+ export function formatKnowledgeHeader(resultCount, matchMode) {
72
+ if (matchMode === "relaxed") {
73
+ return `🧠 No exact match. ${resultCount} closest ${resultCount === 1 ? "entry" : "entries"} ` +
74
+ `(widened search — treat as leads, not confirmed answers):`;
75
+ }
76
+ return `🧠 Found ${resultCount} knowledge entries:`;
77
+ }
78
+ /**
79
+ * Header + per-hit scoring for session_search_memory results.
80
+ *
81
+ * Exported so wording is covered by tests that call the shipped functions —
82
+ * a lesson from the knowledge_search match_mode fix, whose first tests
83
+ * mirrored the logic and would have passed while the handler regressed.
84
+ *
85
+ * Hybrid detection is per-row: portal fusion (weighted RRF, measured 59%
86
+ * blind hit@1 vs 45% semantic-only) annotates each row with semantic_rank /
87
+ * lexical_rank. Any row carrying lexical_rank means the lexical arm ran, so
88
+ * calling the results "semantically similar" would misstate how they were
89
+ * found — and a lexical-only rescue has NO similarity score at all, which
90
+ * previously rendered as "N/A similar".
91
+ */
92
+ export function isHybridSearchResults(results) {
93
+ return results.some((r) => r?.lexical_rank !== undefined && r?.lexical_rank !== null);
94
+ }
95
+ export function searchResultsHeader(count, hybrid) {
96
+ return hybrid
97
+ ? `🧠 Found ${count} matching sessions (hybrid retrieval — semantic meaning + exact terms):`
98
+ : `🧠 Found ${count} semantically similar sessions:`;
99
+ }
100
+ export function formatHitScore(r) {
101
+ const sim = typeof r.similarity === "number" ? `${(r.similarity * 100).toFixed(1)}% similar` : null;
102
+ const sem = r.semantic_rank !== undefined && r.semantic_rank !== null ? `sem#${r.semantic_rank + 1}` : null;
103
+ const lex = r.lexical_rank !== undefined && r.lexical_rank !== null ? `lex#${r.lexical_rank + 1}` : null;
104
+ if (sem || lex) {
105
+ const arms = [sem, lex].filter(Boolean).join(" + ");
106
+ return sim ? `${sim} (${arms})` : `exact-term match (${arms})`;
107
+ }
108
+ return sim ?? "N/A similar";
109
+ }
60
110
  export async function knowledgeSearchHandler(args) {
61
111
  if (!isKnowledgeSearchArgs(args)) {
62
112
  throw new Error("Invalid arguments for knowledge_search");
@@ -136,7 +186,7 @@ export async function knowledgeSearchHandler(args) {
136
186
  // Phase 1: Wrap in contentBlocks array for optional trace attachment
137
187
  const contentBlocks = [{
138
188
  type: "text",
139
- text: `🧠 Found ${resultCount} knowledge entries:\n\n${JSON.stringify(data.results, null, 2)}`,
189
+ text: `${formatKnowledgeHeader(resultCount, data.match_mode)}\n\n${JSON.stringify(data.results, null, 2)}`,
140
190
  }];
141
191
  // Phase 1: Attach MemoryTrace with strategy="keyword" and timing data
142
192
  if (enable_trace) {
@@ -377,6 +427,8 @@ export async function sessionSearchMemoryHandler(args) {
377
427
  : Math.min(limit, 20);
378
428
  const results = await storage.searchMemory({
379
429
  queryEmbedding: JSON.stringify(queryEmbedding),
430
+ // Portal-backed installs fuse this with lexical search (weighted RRF).
431
+ queryText: query,
380
432
  project: project || null,
381
433
  limit: candidateLimit,
382
434
  similarityThreshold: similarity_threshold,
@@ -516,9 +568,7 @@ export async function sessionSearchMemoryHandler(args) {
516
568
  }
517
569
  // Format results with similarity scores + effective importance + ACT-R
518
570
  const formatted = results.map((r, i) => {
519
- const simScore = typeof r.similarity === "number"
520
- ? `${(r.similarity * 100).toFixed(1)}%`
521
- : "N/A";
571
+ const simScore = formatHitScore(r);
522
572
  // Dynamic importance decay (uses ACT-R internally when enabled)
523
573
  const baseImportance = r.importance ?? 0;
524
574
  const effectiveImportance = computeEffectiveImportance(baseImportance, r.last_accessed_at, r.created_at, Boolean(r.is_rollup));
@@ -529,7 +579,7 @@ export async function sessionSearchMemoryHandler(args) {
529
579
  const actrStr = r._actr_composite !== undefined
530
580
  ? ` ACT-R: composite=${r._actr_composite.toFixed(3)} (B=${r._actr_Bi?.toFixed(2)}, S=${r._actr_Si?.toFixed(3)})\n`
531
581
  : "";
532
- return `[${i + 1}] ${simScore} similar — ${r.session_date || "unknown date"}\n` +
582
+ return `[${i + 1}] ${simScore} — ${r.session_date || "unknown date"}\n` +
533
583
  ` Project: ${r.project}\n` +
534
584
  ` Summary: ${r.summary}\n` +
535
585
  importanceStr +
@@ -540,7 +590,7 @@ export async function sessionSearchMemoryHandler(args) {
540
590
  // Phase 1: content[0] = human-readable results (unchanged from pre-Phase 1)
541
591
  const contentBlocks = [{
542
592
  type: "text",
543
- text: `🧠 Found ${results.length} semantically similar sessions:\n\n${formatted}`,
593
+ text: `${searchResultsHeader(results.length, isHybridSearchResults(results))}\n\n${formatted}`,
544
594
  }];
545
595
  // Phase 1: content[1] = machine-readable MemoryTrace (only when enable_trace=true)
546
596
  // topScore is read from results[0].similarity — this is the cosine distance
@@ -60,22 +60,36 @@ export async function backfillEmbeddingsHandler(args) {
60
60
  debugLog(`[backfill_embeddings] ${dry_run ? "DRY RUN: " : ""}` +
61
61
  `project=${project || "all"}, limit=${safeLimit}`);
62
62
  const storage = await getStorage();
63
- // Find entries missing embeddings
64
- const params = {
65
- "embedding": "is.null",
66
- "archived_at": "is.null",
67
- user_id: `eq.${PRISM_USER_ID}`,
68
- order: "id.asc",
69
- limit: String(safeLimit),
70
- select: "id,summary,decisions,project",
71
- };
72
- if (args._cursor_id) {
73
- params.id = `gt.${args._cursor_id}`;
63
+ // Find entries missing embeddings. Prefer the dedicated method: the old
64
+ // PostgREST-param path below goes through getLedgerEntries, which
65
+ // SynaluxStorage inherits as a DIRECT Supabase read — paid-tier installs
66
+ // have no SUPABASE_URL, so that read dies with NXDOMAIN and this tool
67
+ // could never see what it was supposed to repair.
68
+ let entries;
69
+ if (typeof storage.getEntriesMissingEmbeddings === "function") {
70
+ entries = await storage.getEntriesMissingEmbeddings({
71
+ limit: safeLimit,
72
+ ...(project ? { project } : {}),
73
+ ...(args._cursor_id ? { cursorId: args._cursor_id } : {}),
74
+ });
74
75
  }
75
- if (project) {
76
- params.project = `eq.${project}`;
76
+ else {
77
+ const params = {
78
+ "embedding": "is.null",
79
+ "archived_at": "is.null",
80
+ user_id: `eq.${PRISM_USER_ID}`,
81
+ order: "id.asc",
82
+ limit: String(safeLimit),
83
+ select: "id,summary,decisions,project",
84
+ };
85
+ if (args._cursor_id) {
86
+ params.id = `gt.${args._cursor_id}`;
87
+ }
88
+ if (project) {
89
+ params.project = `eq.${project}`;
90
+ }
91
+ entries = await storage.getLedgerEntries(params);
77
92
  }
78
- const entries = await storage.getLedgerEntries(params);
79
93
  if (entries.length === 0) {
80
94
  return {
81
95
  content: [{
@@ -627,6 +627,7 @@ export async function sessionSaveHandoffHandler(args, server) {
627
627
  // Await the attempt so short-lived CLI/MCP transports cannot exit before the
628
628
  // snapshot reaches storage. Failure stays non-fatal because the primary
629
629
  // handoff has already been durably saved.
630
+ let historySnapshotSaved = false;
630
631
  if (data.status === "created" || data.status === "updated") {
631
632
  const snapshotEntry = {
632
633
  project,
@@ -637,10 +638,12 @@ export async function sessionSaveHandoffHandler(args, server) {
637
638
  keywords: keywords ?? null,
638
639
  key_context: key_context ?? null,
639
640
  active_branch: active_branch ?? null,
641
+ role: effectiveRole,
640
642
  version: newVersion,
641
643
  };
642
644
  try {
643
- await storage.saveHistorySnapshot(snapshotEntry);
645
+ await storage.saveHistorySnapshot(snapshotEntry, active_branch ?? "main");
646
+ historySnapshotSaved = true;
644
647
  }
645
648
  catch (err) {
646
649
  console.error(`[session_save_handoff] History snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
@@ -800,12 +803,16 @@ export async function sessionSaveHandoffHandler(args, server) {
800
803
  console.error("[FactMerger] Module load failed (non-fatal): " + err));
801
804
  }
802
805
  const metricsBlock = formatInferenceMetrics();
806
+ const historySnapshotLine = historySnapshotSaved
807
+ ? `🕘 Versioned history snapshot saved.\n`
808
+ : `⚠️ Primary handoff saved, but its versioned history snapshot was not saved. Check memory_history before relying on time travel.\n`;
803
809
  // Build response text based on whether a CRDT merge occurred
804
810
  const responseText = (_saveHandoffGateWarning ? `⚠️ ${_saveHandoffGateWarning}\n\n` : "") +
805
811
  (isMerged
806
812
  ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
807
813
  `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
808
814
  (last_summary ? `Summary: ${last_summary}\n` : "") +
815
+ historySnapshotLine +
809
816
  metricsBlock +
810
817
  `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
811
818
  `to maintain concurrency control.`
@@ -814,6 +821,7 @@ export async function sessionSaveHandoffHandler(args, server) {
814
821
  (last_summary ? `Last summary: ${last_summary}\n` : "") +
815
822
  (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
816
823
  (active_branch ? `Active branch: ${active_branch}\n` : "") +
824
+ historySnapshotLine +
817
825
  (embeddingQueued
818
826
  ? `📊 Embedding generation queued for semantic search.\n`
819
827
  : `📊 Primary history saved; optional semantic indexing was not queued.\n`) +
@@ -217,7 +217,21 @@ export function runHealthCheck(stats) {
217
217
  severity: stats.missingEmbeddings > 10 ? "error" : "warning", // >10 = critical
218
218
  message: `${stats.missingEmbeddings} ledger entries have no embedding vector`,
219
219
  count: stats.missingEmbeddings, // how many are affected
220
- suggestion: "Run session_health_check(auto_fix: true) to generate missing embeddings automatically",
220
+ suggestion: "Run session_backfill_embeddings to generate the missing vectors",
221
+ });
222
+ }
223
+ else if (stats.missingEmbeddings < 0) {
224
+ // -1 = coverage is UNKNOWN (portal unreachable, or a portal that predates
225
+ // the ledger_missing_embeddings field). Saying nothing here is how a
226
+ // 100%-missing outage was certified "HEALTHY — all clean": the storage
227
+ // layer hardcoded 0 and this check had no way to distinguish "verified
228
+ // zero" from "never looked".
229
+ issues.push({
230
+ check: "missing_embeddings",
231
+ severity: "warning",
232
+ message: "Embedding coverage could not be verified (portal did not report a count)",
233
+ count: 0,
234
+ suggestion: "Update the Synalux portal, or check connectivity, then re-run session_health_check",
221
235
  });
222
236
  }
223
237
  // ── Check 2: Duplicate Entries ─────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.2.9",
3
+ "version": "20.3.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",