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 +25 -7
- package/dist/cli.js +45 -0
- package/dist/connect.js +7 -0
- package/dist/evidenceWorkflowPolicy.js +19 -0
- package/dist/mcpTransportHealth.js +10 -2
- package/dist/server.js +2 -0
- package/dist/skillManifestSync.js +9 -6
- package/dist/storage/portalContracts.js +4 -0
- package/dist/storage/synalux.js +143 -7
- package/dist/tools/behavioralVerifierHandler.js +20 -4
- package/dist/tools/graphHandlers.js +56 -6
- package/dist/tools/hygieneHandlers.js +28 -14
- package/dist/tools/ledgerHandlers.js +133 -80
- package/dist/tools/sessionMemoryDefinitions.js +2 -1
- package/dist/tools/skillRouting.js +3 -2
- package/dist/utils/codingQualityPolicy.js +9 -1
- package/dist/utils/healthCheck.js +15 -1
- package/package.json +2 -2
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
185
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/dist/storage/synalux.js
CHANGED
|
@@ -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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = `${
|
|
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 =
|
|
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:
|
|
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 =
|
|
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}
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
params
|
|
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
|
|
151
|
-
? syncResult.entitledNames
|
|
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 === "
|
|
232
|
+
if (snapshot.source === "tier-fallback") {
|
|
220
233
|
return `> **Prism System Ready**\n>\n` +
|
|
221
234
|
`> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
|
|
222
|
-
`> - 🛡️ **
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
|
625
|
+
// ─── TIME MACHINE: Auto-snapshot for time travel ───
|
|
603
626
|
// Every successful save creates a snapshot so the user can revert later.
|
|
604
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1965
|
-
.
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
-
|
|
1970
|
-
|
|
1971
|
-
.
|
|
1972
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|