prism-mcp-server 20.2.8 → 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
@@ -148,13 +163,16 @@ that would require a host lifecycle hook, launcher, extension, or Prism-owned
148
163
  panel. Context loading itself remains complete even when a host shortens the
149
164
  visible reply.
150
165
 
151
- Free accounts receive the protected 13-skill foundation. It includes
152
- `current-staging-acceptance`, the strict completion extension of
153
- `evidence-first-protocol`: agents must use the exact current staging artifact
154
- and inspect every case and screenshot before reporting acceptance. Paid
155
- accounts receive the current subscribed routing set. Upgrades install newly
156
- entitled packages; downgrades remove only Prism-owned packages while preserving
157
- local skills and locally modified conflicts.
166
+ Free accounts receive only the public hook-free `prism-startup` package; the MCP
167
+ server still supplies a compact, non-proprietary safety and evidence contract.
168
+ Authenticated paid accounts receive the protected behavioral and engineering
169
+ packages plus the current subscribed routing set. The paid
170
+ `evidence-first-protocol` keeps ordinary coding lightweight: one correlated
171
+ reproduction is enough to begin an edit, while strict acceptance starts only
172
+ before a completion claim, push, or release and inspects only the exact artifacts
173
+ used as proof. Upgrades install newly entitled packages; verified downgrades
174
+ remove only Prism-owned packages while preserving local skills and locally
175
+ modified conflicts.
158
176
 
159
177
  When upgrading an older Claude Code installation, `prism connect` removes only
160
178
  the exact Prism-owned startup, skill-sync, handoff, and drift hook actions from
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCode
12
12
  import { runBrowserCli } from './browserCli.js';
13
13
  import { filterPrismMemoryContext } from './utils/memoryQuality.js';
14
14
  import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
15
+ import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
15
16
  const program = new Command();
16
17
  /** Build the stable `prism load --json` envelope from depth-specific context. */
17
18
  export function buildLoadJsonOutput(project, data, level, metadata) {
@@ -111,6 +112,50 @@ program
111
112
  .command('bootstrap')
112
113
  .description('Print the canonical dashboard-configured first-turn Prism greeting')
113
114
  .action(runBootstrapCommand);
115
+ /**
116
+ * One-shot fallback for hosts whose long-lived MCP transport has closed.
117
+ * This calls the canonical handler, including its authenticated portal path
118
+ * and fail-closed offline scenario; it never asks the host to invent one.
119
+ */
120
+ export async function runVerifyBehaviorCommand(options) {
121
+ try {
122
+ const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
123
+ process.env.SYNALUX_BASE_URL?.trim() ||
124
+ (await getSetting('PRISM_SYNALUX_BASE_URL', '')).trim() ||
125
+ (await getSetting('SYNALUX_BASE_URL', '')).trim() ||
126
+ 'https://synalux.ai';
127
+ const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim() ||
128
+ (await getSetting('PRISM_SYNALUX_API_KEY', '')).trim();
129
+ process.env.PRISM_SYNALUX_BASE_URL = baseUrl.replace(/\/+$/, '');
130
+ if (apiKey)
131
+ process.env.PRISM_SYNALUX_API_KEY = apiKey;
132
+ const result = await verifyBehaviorHandler({
133
+ file_path: options.file,
134
+ change_summary: options.summary,
135
+ ...(options.project ? { project: options.project } : {}),
136
+ ...(options.workspaceId ? { workspace_id: options.workspaceId } : {}),
137
+ });
138
+ const output = result.content
139
+ ?.map((part) => part?.text)
140
+ .filter(Boolean)
141
+ .join('\n') || '';
142
+ if (!output)
143
+ throw new Error('verify_behavior returned no scenario');
144
+ console.log(output);
145
+ }
146
+ catch (err) {
147
+ console.error(`Behavioral verification failed: ${err instanceof Error ? err.message : String(err)}`);
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+ program
152
+ .command('verify-behavior')
153
+ .description('Get the behavioral edit scenario when an MCP transport is unavailable')
154
+ .requiredOption('--file <path>', 'Path of the file about to be edited')
155
+ .requiredOption('--summary <text>', 'Brief description of the intended change')
156
+ .option('--project <project>', 'Project identifier for workspace-scoped scenarios')
157
+ .option('--workspace-id <id>', 'Workspace ID for custom scenarios')
158
+ .action(runVerifyBehaviorCommand);
114
159
  // Parsed by the direct dispatch at the bottom so all Python CLI flags pass
115
160
  // through unchanged. Registering it here keeps the command visible in help.
116
161
  program
package/dist/connect.js CHANGED
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as w
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
+ import { EVIDENCE_WORKFLOW_POLICY_LINES } from "./evidenceWorkflowPolicy.js";
7
8
  import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
8
9
  export const CONNECT_HOSTS = [
9
10
  "claude-code",
@@ -46,6 +47,8 @@ const CODEX_STARTUP_BODY = [
46
47
  "block is managed by `prism connect`; do not edit it manually.",
47
48
  "",
48
49
  ...LOCAL_FIRST_POLICY_LINES,
50
+ "",
51
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
49
52
  ];
50
53
  const CONNECT_STORAGE_BACKENDS = ["auto", "local", "synalux", "supabase"];
51
54
  const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
@@ -542,6 +545,8 @@ function serializeClaudeStartupBlock(newline) {
542
545
  "managed by `prism connect`; do not edit it manually.",
543
546
  "",
544
547
  ...LOCAL_FIRST_POLICY_LINES,
548
+ "",
549
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
545
550
  CLAUDE_STARTUP_MANAGED_END,
546
551
  "",
547
552
  ].join(newline);
@@ -617,6 +622,8 @@ function serializeGeminiStartupBlock(newline) {
617
622
  "session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
618
623
  "",
619
624
  ...LOCAL_FIRST_POLICY_LINES,
625
+ "",
626
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
620
627
  GEMINI_STARTUP_MANAGED_END,
621
628
  "",
622
629
  ].join(newline);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Minimum evidence contract shared by every MCP host.
3
+ *
4
+ * Native-skill hosts also receive the full evidence-first protocol. Keep this
5
+ * compact copy in MCP initialize instructions so hosts without a filesystem
6
+ * skill surface, including Claude Desktop, still follow the same workflow.
7
+ */
8
+ export const EVIDENCE_WORKFLOW_POLICY_LINES = [
9
+ "## Prism evidence workflow",
10
+ "During diagnosis and editing, one trustworthy correlated reproduction is enough; do not block coding on",
11
+ "inspecting unrelated diagnostic screenshots, trace frames, or abandoned attempts.",
12
+ "Before a completion claim, push, or release, exercise the corrected path with fresh evidence from the current",
13
+ "build and bind stateful proof to the exact run and entity. Inspect every artifact used to support the claim.",
14
+ "When a screenshot is requested or used as proof, the active agent must open it and compare its visible state",
15
+ "with the issue's expected and forbidden states. Complete that review yourself; do not ask the user to verify it.",
16
+ "A screenshot is an observation, not absolute truth. Reject stale, wrong-run, wrong-entity, or visibly failing",
17
+ "evidence even when its metadata says passed.",
18
+ ];
19
+ export const EVIDENCE_WORKFLOW_POLICY_TEXT = EVIDENCE_WORKFLOW_POLICY_LINES.join(" ");
@@ -28,8 +28,16 @@ export function monitorMcpTransport(server, options) {
28
28
  options.onFailure(reason, error);
29
29
  };
30
30
  const handleClose = () => {
31
- previousOnClose?.();
32
- fail("MCP_TRANSPORT_CLOSED");
31
+ let closeError;
32
+ try {
33
+ previousOnClose?.();
34
+ }
35
+ catch (error) {
36
+ closeError = error instanceof Error ? error : new Error(String(error));
37
+ }
38
+ finally {
39
+ fail("MCP_TRANSPORT_CLOSED", closeError);
40
+ }
33
41
  };
34
42
  server.onclose = handleClose;
35
43
  const check = async () => {
package/dist/server.js CHANGED
@@ -85,6 +85,7 @@ import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
85
85
  import { recordInvocation } from "./utils/analytics.js";
86
86
  import { BOUNDARIES_TEXT } from "./boundaries/boundaries.js";
87
87
  import { triggerSkillManifestSync } from "./skillManifestSync.js";
88
+ import { EVIDENCE_WORKFLOW_POLICY_TEXT } from "./evidenceWorkflowPolicy.js";
88
89
  import { LOCAL_FIRST_POLICY_TEXT } from "./localFirstPolicy.js";
89
90
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
90
91
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
@@ -328,6 +329,7 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
328
329
  `Reuse the conversation_id returned by session_bootstrap in structuredContent for those saves and for ` +
329
330
  `session_detect_drift, the 60-minute goal-alignment drift check. Do not add the id to the visible greeting.\n\n` +
330
331
  `${LOCAL_FIRST_POLICY_TEXT}\n\n` +
332
+ `${EVIDENCE_WORKFLOW_POLICY_TEXT}\n\n` +
331
333
  `Architecture: session_save_ledger and session_save_handoff require context loaded by session_bootstrap ` +
332
334
  `or session_load_context when a conversation_id is supplied. ${BOUNDARIES_TEXT} ` +
333
335
  `All cloud inference routes through the Synalux portal for billing, tier-gating, and audit.`;
@@ -4,7 +4,7 @@ import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realp
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
7
- import { REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
7
+ import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
8
8
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
9
9
  const OWNER = "prism-skill-sync-v1";
10
10
  const MARKER = ".prism-managed.json";
@@ -181,17 +181,20 @@ export function validateSkillManifest(payload) {
181
181
  }
182
182
  }
183
183
  }
184
- const requiredNames = new Set(REQUIRED_NATIVE_SKILL_NAMES);
185
- for (const required of REQUIRED_NATIVE_SKILL_NAMES) {
184
+ const requiredForTier = value.tier === "free"
185
+ ? FREE_NATIVE_SKILL_NAMES
186
+ : REQUIRED_NATIVE_SKILL_NAMES;
187
+ const requiredNames = new Set(requiredForTier);
188
+ for (const required of requiredForTier) {
186
189
  const requiredSkill = skills.find((skill) => skill.name === required);
187
190
  if (!requiredSkill)
188
- throw new Error(`manifest is missing required protected skill: ${required}`);
191
+ throw new Error(`manifest is missing required native skill: ${required}`);
189
192
  if (!requiredSkill.metadata.protected || !requiredSkill.metadata.categories.includes("universal")) {
190
- throw new Error(`required skill is not protected universal: ${required}`);
193
+ throw new Error(`required native skill is not protected universal: ${required}`);
191
194
  }
192
195
  }
193
196
  if (value.tier === "free" && (skills.length !== requiredNames.size || skills.some((skill) => !requiredNames.has(skill.name)))) {
194
- throw new Error("free manifest must contain exactly the protected skill floor");
197
+ throw new Error("free manifest must contain exactly the public startup package");
195
198
  }
196
199
  const normalized = {
197
200
  schema_version: 1,
@@ -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
  });
@@ -20,21 +20,35 @@
20
20
  * Methods migrated to portal:
21
21
  * - saveLedger → POST /api/v1/prism/memory action=save_ledger
22
22
  * - saveHandoff → POST /api/v1/prism/memory action=save_handoff
23
+ * - saveHistorySnapshot → POST /api/v1/prism/memory action=save_history_snapshot
23
24
  * - loadContext → POST /api/v1/prism/memory action=load_context
24
25
  * - searchKnowledge → POST /api/v1/prism/memory action=search
25
26
  * - softDeleteLedger → POST /api/v1/prism/memory action=forget_memory (Phase 3 Tier A)
26
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
27
32
  *
28
33
  * Methods still falling through to SupabaseStorage (Phase 3 Tier B+):
29
- * semantic searchMemory, save_experience direct entrypoint,
30
- * 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.
31
45
  * See portal/docs/PHASE_3_PORTAL_ENDPOINTS.md for the full catalog.
32
46
  * ═══════════════════════════════════════════════════════════════════
33
47
  */
34
48
  import { SupabaseStorage } from "./supabase.js";
35
49
  import { debugLog } from "../utils/logger.js";
36
50
  import { PRISM_SYNALUX_BASE_URL, PRISM_SYNALUX_API_KEY } from "../config.js";
37
- import { KnowledgeSearchRequestSchema } from "./portalContracts.js";
51
+ import { KnowledgeSearchRequestSchema, KnowledgeSearchResponseSchema } from "./portalContracts.js";
38
52
  function resolveKnowledgeScope(callerScope) {
39
53
  if (callerScope === "user" || callerScope === "workspace") {
40
54
  return callerScope;
@@ -203,7 +217,46 @@ export class SynaluxStorage extends SupabaseStorage {
203
217
  role: handoff.role,
204
218
  expected_version: expectedVersion ?? undefined,
205
219
  });
206
- return (result.handoff ?? result);
220
+ const candidate = Object.prototype.hasOwnProperty.call(result, "result")
221
+ ? result.result
222
+ : Object.prototype.hasOwnProperty.call(result, "handoff")
223
+ ? result.handoff
224
+ : result;
225
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
226
+ throw new Error("[SynaluxStorage] Invalid save_handoff response: missing result");
227
+ }
228
+ const value = candidate;
229
+ if (value.status === "conflict" && Number.isSafeInteger(value.current_version)) {
230
+ return {
231
+ status: "conflict",
232
+ current_version: value.current_version,
233
+ };
234
+ }
235
+ if ((value.status === "created" || value.status === "updated")
236
+ && Number.isSafeInteger(value.version)) {
237
+ return {
238
+ status: value.status,
239
+ version: value.version,
240
+ };
241
+ }
242
+ // Older portal RPC wrappers returned only the new version. Preserve that
243
+ // rolling-upgrade contract without accepting an unversioned success.
244
+ if (value.status === undefined && Number.isSafeInteger(value.version)) {
245
+ return {
246
+ status: "updated",
247
+ version: value.version,
248
+ };
249
+ }
250
+ throw new Error("[SynaluxStorage] Invalid save_handoff response: malformed OCC result");
251
+ }
252
+ async saveHistorySnapshot(handoff, branch = "main") {
253
+ await this.portalPost("/api/v1/prism/memory", {
254
+ action: "save_history_snapshot",
255
+ project: handoff.project,
256
+ version: handoff.version,
257
+ snapshot: handoff,
258
+ branch,
259
+ });
207
260
  }
208
261
  // ─── Context ─────────────────────────────────────────────────
209
262
  async loadContext(project, level, userId, role) {
@@ -290,6 +343,8 @@ export class SynaluxStorage extends SupabaseStorage {
290
343
  const result = await this.portalPost("/api/v1/prism/memory", {
291
344
  action: "search_memory",
292
345
  project: params.project ?? undefined,
346
+ // Enables the portal's hybrid lexical+semantic fusion (see interface).
347
+ query: params.queryText || undefined,
293
348
  query_embedding: parsed,
294
349
  similarity_threshold: params.similarityThreshold,
295
350
  limit: params.limit,
@@ -318,9 +373,81 @@ export class SynaluxStorage extends SupabaseStorage {
318
373
  scope: resolveKnowledgeScope(params.scope),
319
374
  });
320
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
+ }
321
389
  const count = typeof result.count === "number" ? result.count : 0;
322
390
  const results = Array.isArray(result.results) ? result.results : [];
323
- 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;
324
451
  }
325
452
  // ─── Time Travel ─────────────────────────────────────────────
326
453
  // Phase 3 Tier B: route memory_history through portal instead of
@@ -357,8 +484,15 @@ export class SynaluxStorage extends SupabaseStorage {
357
484
  const inventory = result.inventory;
358
485
  const totalActiveEntries = typeof inventory?.ledger_entries === "number" ? inventory.ledger_entries : 0;
359
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;
360
494
  return {
361
- missingEmbeddings: 0,
495
+ missingEmbeddings,
362
496
  activeLedgerSummaries: [],
363
497
  orphanedHandoffs: [],
364
498
  staleRollups: 0,
@@ -371,7 +505,9 @@ export class SynaluxStorage extends SupabaseStorage {
371
505
  catch (e) {
372
506
  debugLog("[SynaluxStorage] getHealthStats failed: " + (e instanceof Error ? e.message : String(e)));
373
507
  return {
374
- 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,
375
511
  activeLedgerSummaries: [],
376
512
  orphanedHandoffs: [],
377
513
  staleRollups: 0,
@@ -8,7 +8,7 @@
8
8
  * FAIL-CLOSED: if the portal is unreachable, returns a generic
9
9
  * verification challenge rather than skipping verification.
10
10
  */
11
- import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED } from "../config.js";
11
+ import { PRISM_SYNALUX_BASE_URL } from "../config.js";
12
12
  import { getSynaluxJwt } from "../utils/synaluxJwt.js";
13
13
  const FALLBACK_SCENARIO = [
14
14
  "⚠️ BEHAVIORAL VERIFICATION (OFFLINE MODE)",
@@ -33,7 +33,13 @@ export async function verifyBehaviorHandler(args) {
33
33
  return { content: [{ type: "text", text: await buildScenarioText(args) }] };
34
34
  }
35
35
  async function buildScenarioText(args) {
36
- if (!SYNALUX_CONFIGURED || !PRISM_SYNALUX_BASE_URL) {
36
+ const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
37
+ process.env.SYNALUX_BASE_URL?.trim() ||
38
+ PRISM_SYNALUX_BASE_URL;
39
+ // OAuth/JWT-backed installs intentionally do not copy a long-lived API key
40
+ // into host configuration. A valid portal URL is enough to attempt the
41
+ // short-lived JWT flow; getSynaluxJwt() remains the fail-closed auth gate.
42
+ if (!baseUrl) {
37
43
  return FALLBACK_SCENARIO;
38
44
  }
39
45
  const jwt = await getSynaluxJwt();
@@ -42,7 +48,7 @@ async function buildScenarioText(args) {
42
48
  return FALLBACK_SCENARIO;
43
49
  }
44
50
  try {
45
- const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/verify-behavior`;
51
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/prism/verify-behavior`;
46
52
  const res = await fetch(url, {
47
53
  method: "POST",
48
54
  headers: {
@@ -60,7 +66,11 @@ async function buildScenarioText(args) {
60
66
  console.error(`[verify-behavior] ⚠️ portal returned ${res.status} — fail-closed. URL: ${url}`);
61
67
  return FALLBACK_SCENARIO;
62
68
  }
63
- const data = (await res.json());
69
+ const data = await res.json();
70
+ if (!isVerifyBehaviorResult(data)) {
71
+ console.error("[verify-behavior] ⚠️ portal returned a malformed response — fail-closed");
72
+ return FALLBACK_SCENARIO;
73
+ }
64
74
  return formatResult(data);
65
75
  }
66
76
  catch (err) {
@@ -68,6 +78,12 @@ async function buildScenarioText(args) {
68
78
  return FALLBACK_SCENARIO;
69
79
  }
70
80
  }
81
+ function isVerifyBehaviorResult(value) {
82
+ return typeof value === "object" &&
83
+ value !== null &&
84
+ !Array.isArray(value) &&
85
+ typeof value.requires_verification === "boolean";
86
+ }
71
87
  function formatResult(data) {
72
88
  if (!data.requires_verification) {
73
89
  return JSON.stringify({ requires_verification: false, reason: data.reason || "non-behavioral file" });
@@ -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: [{
@@ -142,29 +142,42 @@ function parseNativeSkillNames(value) {
142
142
  }
143
143
  }
144
144
  async function resolveNativeSkillManifestSnapshot(syncResult) {
145
- const { REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
145
+ const { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } = await import("./skillRouting.js");
146
146
  const [storedNamesValue, storedTierValue] = await Promise.all([
147
147
  getSetting("skill_manifest:names", "[]"),
148
148
  getSetting("skill_manifest:tier", ""),
149
149
  ]);
150
- const partialNames = syncResult.status === "partial" && Array.isArray(syncResult.entitledNames)
151
- ? syncResult.entitledNames.filter((name) => typeof name === "string" && NATIVE_SKILL_NAME.test(name))
150
+ const partialNamesInput = syncResult.status === "partial" && Array.isArray(syncResult.entitledNames)
151
+ ? syncResult.entitledNames
152
+ : null;
153
+ const partialProvided = partialNamesInput !== null;
154
+ const partialNames = partialNamesInput
155
+ ? partialNamesInput.filter((name) => typeof name === "string" && NATIVE_SKILL_NAME.test(name))
152
156
  : [];
153
157
  const storedNames = parseNativeSkillNames(storedNamesValue);
154
- const names = partialNames.length > 0
155
- ? [...new Set(partialNames)]
156
- : storedNames.length > 0
157
- ? storedNames
158
- : [...REQUIRED_NATIVE_SKILL_NAMES];
159
- const source = partialNames.length > 0
160
- ? "validated-partial"
161
- : storedNames.length > 0
162
- ? "committed"
163
- : "protected-fallback";
164
158
  const candidateTier = syncResult.status === "partial" ? syncResult.tier : storedTierValue || syncResult.tier;
165
159
  const tier = typeof candidateTier === "string" && SYNALUX_TIERS.has(candidateTier)
166
160
  ? candidateTier
167
161
  : "free";
162
+ const tierFallbackNames = tier === "free"
163
+ ? FREE_NATIVE_SKILL_NAMES
164
+ : REQUIRED_NATIVE_SKILL_NAMES;
165
+ const freeNames = new Set(FREE_NATIVE_SKILL_NAMES);
166
+ const namesForTier = (names) => tier === "free"
167
+ ? names.filter((name) => freeNames.has(name))
168
+ : names;
169
+ const entitledPartialNames = namesForTier([...new Set(partialNames)]);
170
+ const entitledStoredNames = namesForTier(storedNames);
171
+ const names = partialProvided
172
+ ? entitledPartialNames.length > 0 ? entitledPartialNames : [...tierFallbackNames]
173
+ : entitledStoredNames.length > 0
174
+ ? entitledStoredNames
175
+ : [...tierFallbackNames];
176
+ const source = partialProvided
177
+ ? "validated-partial"
178
+ : entitledStoredNames.length > 0
179
+ ? "committed"
180
+ : "tier-fallback";
168
181
  return {
169
182
  names,
170
183
  tier,
@@ -216,10 +229,10 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
216
229
  `> - 🧠 **Context depth:** ${depth}\n` +
217
230
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}`;
218
231
  }
219
- if (snapshot.source === "protected-fallback") {
232
+ if (snapshot.source === "tier-fallback") {
220
233
  return `> **Prism System Ready**\n>\n` +
221
234
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
222
- `> - 🛡️ **Protected fallback names:** ${formatBoundedSkillNames(coreSkills, "fallback")}\n` +
235
+ `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
223
236
  `> - 🧠 **Context depth:** ${depth}\n` +
224
237
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}`;
225
238
  }
@@ -341,38 +354,46 @@ export async function sessionSaveLedgerHandler(args) {
341
354
  role: effectiveRole, // v3.0: Hivemind role scoping (dashboard fallback)
342
355
  });
343
356
  // ─── Fire-and-forget embedding generation ───
357
+ let embeddingQueued = false;
344
358
  if (result) {
345
359
  const embeddingText = [summary, ...(decisions || [])].join("\n");
346
360
  const savedEntry = Array.isArray(result) ? result[0] : result;
347
361
  const entryId = savedEntry?.id;
348
362
  if (entryId) {
349
- getLLMProvider().generateEmbedding(embeddingText)
350
- .then(async (embedding) => {
351
- // Build atomic patch — float32 + TurboQuant in ONE DB update
352
- const patchData = {
353
- embedding: JSON.stringify(embedding),
354
- };
355
- // TurboQuant: compress alongside float32 (non-fatal)
356
- try {
357
- const { getDefaultCompressor, serialize } = await import("../utils/turboquant.js");
358
- const compressor = getDefaultCompressor();
359
- const compressed = compressor.compress(embedding);
360
- const buf = serialize(compressed);
361
- patchData.embedding_compressed = buf.toString("base64");
362
- patchData.embedding_format = `turbo${compressor.bits}`;
363
- patchData.embedding_turbo_radius = compressed.radius;
364
- debugLog(`[session_save_ledger] TurboQuant compressed: ${buf.length} bytes (${(3072 / buf.length).toFixed(1)}× ratio)`);
365
- }
366
- catch (turboErr) {
367
- console.error(`[session_save_ledger] TurboQuant compression failed (non-fatal): ${turboErr.message}`);
368
- }
369
- // Single atomic DB update for all embedding data
370
- await storage.patchLedger(entryId, patchData);
371
- debugLog(`[session_save_ledger] Embedding saved for entry ${entryId}`);
372
- })
373
- .catch((err) => {
374
- console.error(`[session_save_ledger] Embedding generation failed (non-fatal): ${err.message}`);
375
- });
363
+ try {
364
+ const embeddingPromise = getLLMProvider().generateEmbedding(embeddingText);
365
+ embeddingQueued = true;
366
+ embeddingPromise
367
+ .then(async (embedding) => {
368
+ // Build atomic patch — float32 + TurboQuant in ONE DB update
369
+ const patchData = {
370
+ embedding: JSON.stringify(embedding),
371
+ };
372
+ // TurboQuant: compress alongside float32 (non-fatal)
373
+ try {
374
+ const { getDefaultCompressor, serialize } = await import("../utils/turboquant.js");
375
+ const compressor = getDefaultCompressor();
376
+ const compressed = compressor.compress(embedding);
377
+ const buf = serialize(compressed);
378
+ patchData.embedding_compressed = buf.toString("base64");
379
+ patchData.embedding_format = `turbo${compressor.bits}`;
380
+ patchData.embedding_turbo_radius = compressed.radius;
381
+ debugLog(`[session_save_ledger] TurboQuant compressed: ${buf.length} bytes (${(3072 / buf.length).toFixed(1)}× ratio)`);
382
+ }
383
+ catch (turboErr) {
384
+ console.error(`[session_save_ledger] TurboQuant compression failed (non-fatal): ${turboErr.message}`);
385
+ }
386
+ // Single atomic DB update for all embedding data
387
+ await storage.patchLedger(entryId, patchData);
388
+ debugLog(`[session_save_ledger] Embedding saved for entry ${entryId}`);
389
+ })
390
+ .catch((err) => {
391
+ console.error(`[session_save_ledger] Embedding generation failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
392
+ });
393
+ }
394
+ catch (err) {
395
+ console.error(`[session_save_ledger] Embedding provider initialization failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
396
+ }
376
397
  }
377
398
  }
378
399
  // ─── v6.0 Phase 3: Fire-and-forget auto-linking ────────────
@@ -439,7 +460,9 @@ export async function sessionSaveLedgerHandler(args) {
439
460
  (todos?.length ? `TODOs: ${todos.length} items\n` : "") +
440
461
  (files_changed?.length ? `Files changed: ${files_changed.length}\n` : "") +
441
462
  (decisions?.length ? `Decisions: ${decisions.length}\n` : "") +
442
- `📊 Embedding generation queued for semantic search.` +
463
+ (embeddingQueued
464
+ ? `📊 Embedding generation queued for semantic search.`
465
+ : `📊 Primary history saved; optional semantic indexing was not queued.`) +
443
466
  metricsBlock +
444
467
  resolverNote,
445
468
  }],
@@ -599,9 +622,12 @@ export async function sessionSaveHandoffHandler(args, server) {
599
622
  }
600
623
  // ─── Success: handoff created or updated ───
601
624
  const newVersion = data.version;
602
- // ─── TIME MACHINE: Auto-snapshot for time travel (fire-and-forget) ───
625
+ // ─── TIME MACHINE: Auto-snapshot for time travel ───
603
626
  // Every successful save creates a snapshot so the user can revert later.
604
- // We don't await this should never block the success response.
627
+ // Await the attempt so short-lived CLI/MCP transports cannot exit before the
628
+ // snapshot reaches storage. Failure stays non-fatal because the primary
629
+ // handoff has already been durably saved.
630
+ let historySnapshotSaved = false;
605
631
  if (data.status === "created" || data.status === "updated") {
606
632
  const snapshotEntry = {
607
633
  project,
@@ -612,11 +638,19 @@ export async function sessionSaveHandoffHandler(args, server) {
612
638
  keywords: keywords ?? null,
613
639
  key_context: key_context ?? null,
614
640
  active_branch: active_branch ?? null,
641
+ role: effectiveRole,
615
642
  version: newVersion,
616
643
  };
617
- storage.saveHistorySnapshot(snapshotEntry).catch(err => console.error(`[session_save_handoff] History snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`));
644
+ try {
645
+ await storage.saveHistorySnapshot(snapshotEntry, active_branch ?? "main");
646
+ historySnapshotSaved = true;
647
+ }
648
+ catch (err) {
649
+ console.error(`[session_save_handoff] History snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
650
+ }
618
651
  }
619
652
  // ─── Fire-and-forget embedding generation (enables semantic search on handoffs) ───
653
+ let embeddingQueued = false;
620
654
  if (data.status === "created" || data.status === "updated") {
621
655
  const embeddingText = [
622
656
  last_summary || "",
@@ -624,30 +658,37 @@ export async function sessionSaveHandoffHandler(args, server) {
624
658
  ...(open_todos || []),
625
659
  ].filter(Boolean).join("\n");
626
660
  if (embeddingText.trim()) {
627
- getLLMProvider().generateEmbedding(embeddingText)
628
- .then(async (embedding) => {
629
- const patchData = {
630
- embedding: JSON.stringify(embedding),
631
- };
632
- try {
633
- const { getDefaultCompressor, serialize } = await import("../utils/turboquant.js");
634
- const compressor = getDefaultCompressor();
635
- const compressed = compressor.compress(embedding);
636
- const buf = serialize(compressed);
637
- patchData.embedding_compressed = buf.toString("base64");
638
- patchData.embedding_format = `turbo${compressor.bits}`;
639
- patchData.embedding_turbo_radius = compressed.radius;
640
- debugLog(`[session_save_handoff] TurboQuant compressed: ${buf.length} bytes`);
641
- }
642
- catch (turboErr) {
643
- console.error(`[session_save_handoff] TurboQuant compression failed (non-fatal): ${turboErr.message}`);
644
- }
645
- await storage.patchHandoff(project, PRISM_USER_ID, patchData);
646
- debugLog(`[session_save_handoff] Embedding saved for project "${project}"`);
647
- })
648
- .catch((err) => {
649
- console.error(`[session_save_handoff] Embedding generation failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
650
- });
661
+ try {
662
+ const embeddingPromise = getLLMProvider().generateEmbedding(embeddingText);
663
+ embeddingQueued = true;
664
+ embeddingPromise
665
+ .then(async (embedding) => {
666
+ const patchData = {
667
+ embedding: JSON.stringify(embedding),
668
+ };
669
+ try {
670
+ const { getDefaultCompressor, serialize } = await import("../utils/turboquant.js");
671
+ const compressor = getDefaultCompressor();
672
+ const compressed = compressor.compress(embedding);
673
+ const buf = serialize(compressed);
674
+ patchData.embedding_compressed = buf.toString("base64");
675
+ patchData.embedding_format = `turbo${compressor.bits}`;
676
+ patchData.embedding_turbo_radius = compressed.radius;
677
+ debugLog(`[session_save_handoff] TurboQuant compressed: ${buf.length} bytes`);
678
+ }
679
+ catch (turboErr) {
680
+ console.error(`[session_save_handoff] TurboQuant compression failed (non-fatal): ${turboErr.message}`);
681
+ }
682
+ await storage.patchHandoff(project, PRISM_USER_ID, patchData);
683
+ debugLog(`[session_save_handoff] Embedding saved for project "${project}"`);
684
+ })
685
+ .catch((err) => {
686
+ console.error(`[session_save_handoff] Embedding generation failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
687
+ });
688
+ }
689
+ catch (err) {
690
+ console.error(`[session_save_handoff] Embedding provider initialization failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
691
+ }
651
692
  }
652
693
  }
653
694
  // ─── Trigger resource subscription notification ───
@@ -762,12 +803,16 @@ export async function sessionSaveHandoffHandler(args, server) {
762
803
  console.error("[FactMerger] Module load failed (non-fatal): " + err));
763
804
  }
764
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`;
765
809
  // Build response text based on whether a CRDT merge occurred
766
810
  const responseText = (_saveHandoffGateWarning ? `⚠️ ${_saveHandoffGateWarning}\n\n` : "") +
767
811
  (isMerged
768
812
  ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
769
813
  `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
770
814
  (last_summary ? `Summary: ${last_summary}\n` : "") +
815
+ historySnapshotLine +
771
816
  metricsBlock +
772
817
  `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
773
818
  `to maintain concurrency control.`
@@ -776,7 +821,10 @@ export async function sessionSaveHandoffHandler(args, server) {
776
821
  (last_summary ? `Last summary: ${last_summary}\n` : "") +
777
822
  (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
778
823
  (active_branch ? `Active branch: ${active_branch}\n` : "") +
779
- `📊 Embedding generation queued for semantic search.\n` +
824
+ historySnapshotLine +
825
+ (embeddingQueued
826
+ ? `📊 Embedding generation queued for semantic search.\n`
827
+ : `📊 Primary history saved; optional semantic indexing was not queued.\n`) +
780
828
  metricsBlock +
781
829
  `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
782
830
  `to maintain concurrency control.`);
@@ -1961,16 +2009,21 @@ export async function sessionSaveExperienceHandler(args) {
1961
2009
  const savedEntry = Array.isArray(result) ? result[0] : result;
1962
2010
  const entryId = savedEntry?.id;
1963
2011
  if (entryId) {
1964
- getLLMProvider().generateEmbedding(embeddingText)
1965
- .then(async (embedding) => {
1966
- await storage.patchLedger(entryId, {
1967
- embedding: JSON.stringify(embedding),
2012
+ try {
2013
+ getLLMProvider().generateEmbedding(embeddingText)
2014
+ .then(async (embedding) => {
2015
+ await storage.patchLedger(entryId, {
2016
+ embedding: JSON.stringify(embedding),
2017
+ });
2018
+ debugLog(`[session_save_experience] Embedding saved for entry ${entryId}`);
2019
+ })
2020
+ .catch((err) => {
2021
+ console.error(`[session_save_experience] Embedding failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1968
2022
  });
1969
- debugLog(`[session_save_experience] Embedding saved for entry ${entryId}`);
1970
- })
1971
- .catch((err) => {
1972
- console.error(`[session_save_experience] Embedding failed (non-fatal): ${err.message}`);
1973
- });
2023
+ }
2024
+ catch (err) {
2025
+ console.error(`[session_save_experience] Embedding provider initialization failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2026
+ }
1974
2027
  }
1975
2028
  }
1976
2029
  return {
@@ -1839,7 +1839,8 @@ export const VERIFY_BEHAVIOR_TOOL = {
1839
1839
  description: "Call BEFORE editing behavioral source files (API routes, ordering logic, billing, auth, migrations). " +
1840
1840
  "Returns a domain-specific scenario you must answer to demonstrate understanding of the end-user impact. " +
1841
1841
  "Example: editing a KDS route returns 'A cook has a 3-item ticket. One item is voided. What should the cook see?' " +
1842
- "Answer the scenario concretely before proceeding with the edit.",
1842
+ "Answer the scenario concretely before proceeding with the edit. If the MCP transport is unavailable, use the " +
1843
+ "packaged `prism verify-behavior` CLI fallback; never fabricate a replacement scenario.",
1843
1844
  inputSchema: {
1844
1845
  type: "object",
1845
1846
  properties: {
@@ -22,11 +22,10 @@ export const REQUIRED_PROTECTED_SKILL_NAMES = [
22
22
  'pre-commit-protocol',
23
23
  'pre-push-audit',
24
24
  'implementation-integrity-audit',
25
- 'current-staging-acceptance',
26
25
  'local-inference-first',
27
26
  ];
28
27
  /**
29
- * Native skills that every subscription tier receives through `prism connect`.
28
+ * Native skills that paid subscription tiers receive through `prism connect`.
30
29
  *
31
30
  * `prism-startup` is deliberately not part of OFFLINE_FALLBACK: it tells the
32
31
  * host to call session_load_context, so injecting it back into that tool's
@@ -37,6 +36,8 @@ export const REQUIRED_NATIVE_SKILL_NAMES = [
37
36
  ...REQUIRED_PROTECTED_SKILL_NAMES,
38
37
  'prism-startup',
39
38
  ];
39
+ /** Public hook-free bootstrap package available without a paid entitlement. */
40
+ export const FREE_NATIVE_SKILL_NAMES = ['prism-startup'];
40
41
  export const OFFLINE_FALLBACK = {
41
42
  version: 1,
42
43
  universal: REQUIRED_PROTECTED_SKILL_NAMES.map((name, priority) => ({ name, priority, protected: true })),
@@ -41,7 +41,9 @@ const UNFENCED_PYTHON_START_RE = /^\s*(?:from\s+\S+\s+import|import\s+\S+|(?:asy
41
41
  const TRAILING_PROSE_LINE_RE = /^(?!(?:async\s+def|def|class|from|import|return|raise|yield|assert|del|global|nonlocal|if|elif|else|for|while|try|except|finally|with|match|case)\b)(?![A-Za-z_]\w*\s*=)[A-Za-z][A-Za-z'’-]*:?(?:\s+(?:[A-Za-z][A-Za-z'’+/-]*|`[^`\r\n]+`|https?:\/\/\S+|\d[\w.,%()+\-]*|[+*=<>-])){2,}[.!?]$/;
42
42
  const TRAILING_URL_LINE_RE = /^https?:\/\/\S+$/i;
43
43
  const PYTHON_INVALID_DEF_RE = /(?:^|\n)\s*(?:async\s+)?def\s+[A-Za-z_]\w*\s*:/m;
44
- const PYTHON_AST_SCRIPT = "import ast,sys; tree=ast.parse(sys.stdin.read()); compile(tree, '<prism-coding-gate>', 'exec')";
44
+ const PYTHON_READY_SENTINEL = "PRISM_PYTHON_READY";
45
+ const PYTHON_AST_SCRIPT = `import ast,sys; print('${PYTHON_READY_SENTINEL}', flush=True); ` +
46
+ "tree=ast.parse(sys.stdin.read()); compile(tree, '<prism-coding-gate>', 'exec')";
45
47
  const PYTHON_COMMANDS = ["python3", "python"];
46
48
  const PYTHON_CHILDREN_KEYS_UNPACK_RE = /\bfor\s+[A-Za-z_]\w*\s*,\s*child(?:_node)?\s+in\s+(?:sorted\(\s*)?[A-Za-z_][\w.]*\.children\.keys\(\)\s*\)?\s*:/;
47
49
  function extractUnfencedPythonCode(output) {
@@ -279,6 +281,12 @@ function pythonSyntaxFailure(code) {
279
281
  if (parsed.error && parsed.error.code === "ENOENT") {
280
282
  continue;
281
283
  }
284
+ const parserStarted = parsed.stdout
285
+ ?.split(/\r?\n/)
286
+ .includes(PYTHON_READY_SENTINEL) === true;
287
+ if (!parserStarted) {
288
+ continue;
289
+ }
282
290
  return parsed.status === 0 ? undefined : "python_syntax_error";
283
291
  }
284
292
  return undefined;
@@ -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.8",
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",
@@ -22,7 +22,7 @@
22
22
  "prebuild": "npm run clean",
23
23
  "build": "tsc && npm run chmod-bins",
24
24
  "chmod-bins": "node -e \"['dist/cli.js','dist/server.js','dist/utils/universalImporter.js'].forEach(f => { try { require('fs').chmodSync(f, 0o755); } catch (e) { console.warn('chmod skipped', f, e.message); } })\"",
25
- "prepublishOnly": "npm run build",
25
+ "prepublishOnly": "node scripts/check-publish-clean.mjs && npm run build",
26
26
  "lint:dashboard": "node scripts/lint-dashboard-es5.cjs",
27
27
  "start": "node dist/server.js",
28
28
  "test": "vitest run",