prism-mcp-server 20.21.16 → 20.21.18
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 +18 -1
- package/dist/connect.js +13 -6
- package/dist/contextRecoveryPolicy.js +21 -0
- package/dist/server.js +4 -2
- package/dist/session/sessionContext.js +66 -9
- package/dist/tools/prismInferHandler.js +31 -17
- package/dist/tools/sessionMemoryDefinitions.js +19 -7
- package/dist/tools/skillRouting.js +4 -4
- package/dist/utils/entitlements.js +5 -6
- package/dist/utils/inferencePolicy.js +78 -5
- package/dist/utils/layer1.js +58 -1
- package/dist/utils/qualityGate.js +29 -2
- package/dist/utils/routeContract.js +66 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -158,6 +158,23 @@ or by re-enabling after each run.
|
|
|
158
158
|
<details>
|
|
159
159
|
<summary>Release history (optional)</summary>
|
|
160
160
|
|
|
161
|
+
## What's New in v20.21.18
|
|
162
|
+
|
|
163
|
+
- Fixed: when a save was refused with `context_not_loaded`, the instructions
|
|
164
|
+
Prism installs for Claude Code, Codex and Gemini CLI forbade the one call
|
|
165
|
+
that recovers. The refusal now prints that call, `session_load_context`
|
|
166
|
+
with the same project and conversation_id, and the installed instructions
|
|
167
|
+
allow it. First-turn startup is unchanged.
|
|
168
|
+
|
|
169
|
+
## What's New in v20.21.17
|
|
170
|
+
|
|
171
|
+
- Fixed: the on-device screen refused some ordinary coding requests. With a
|
|
172
|
+
Synalux account (free included), it now reads each request through a policy
|
|
173
|
+
that Synalux serves, pinned by its SHA-256. Without an account, or with
|
|
174
|
+
images attached, the screen is unchanged.
|
|
175
|
+
- `prism_infer` route mode: set `allow_parallel_calls` to keep a reply that
|
|
176
|
+
calls several of the tools you offered in `allowed_tools`.
|
|
177
|
+
|
|
161
178
|
## What's New in v20.21.16
|
|
162
179
|
|
|
163
180
|
### Conversations: checked on your device, and free with an account
|
|
@@ -1387,7 +1404,7 @@ Prism exposes 40+ MCP tools. The core memory loop:
|
|
|
1387
1404
|
| Tool | What it does |
|
|
1388
1405
|
|---|---|
|
|
1389
1406
|
| `session_bootstrap` | Hook-free first-turn greeting and dashboard-configured context |
|
|
1390
|
-
| `session_load_context` | Explicit project reload or older-server startup fallback |
|
|
1407
|
+
| `session_load_context` | Explicit project reload, recovery after a `context_not_loaded` save refusal, or older-server startup fallback |
|
|
1391
1408
|
| `session_save_ledger` | Append an immutable session log entry |
|
|
1392
1409
|
| `session_save_handoff` | Save live state for the next session |
|
|
1393
1410
|
| `knowledge_search` | Semantic + keyword search over all memories |
|
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 { CONTEXT_RECOVERY_POLICY_LINES } from "./contextRecoveryPolicy.js";
|
|
7
8
|
import { EVIDENCE_WORKFLOW_POLICY_LINES } from "./evidenceWorkflowPolicy.js";
|
|
8
9
|
import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
|
|
9
10
|
export const CONNECT_HOSTS = [
|
|
@@ -44,11 +45,13 @@ const CODEX_STARTUP_BODY = [
|
|
|
44
45
|
"reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
|
|
45
46
|
"after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery to load that",
|
|
46
47
|
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection as",
|
|
47
|
-
"a substitute
|
|
48
|
+
"a substitute, and do not use `session_load_context` in place of it. If discovery or invocation fails, report",
|
|
48
49
|
"`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
|
|
49
50
|
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This hook-free",
|
|
50
51
|
"block is managed by `prism connect`; do not edit it manually.",
|
|
51
52
|
"",
|
|
53
|
+
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
54
|
+
"",
|
|
52
55
|
...LOCAL_FIRST_POLICY_LINES,
|
|
53
56
|
"",
|
|
54
57
|
...EVIDENCE_WORKFLOW_POLICY_LINES,
|
|
@@ -578,11 +581,13 @@ function serializeClaudeStartupBlock(newline) {
|
|
|
578
581
|
"content. For a greeting-only prompt, stop after the verbatim startup display. If `session_bootstrap` is",
|
|
579
582
|
"deferred, use native tool discovery/ToolSearch to load that",
|
|
580
583
|
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection",
|
|
581
|
-
"as a substitute
|
|
584
|
+
"as a substitute, and do not use `session_load_context` in place of it. If discovery or invocation fails, report",
|
|
582
585
|
"`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
|
|
583
586
|
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This block is",
|
|
584
587
|
"managed by `prism connect`; do not edit it manually.",
|
|
585
588
|
"",
|
|
589
|
+
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
590
|
+
"",
|
|
586
591
|
...LOCAL_FIRST_POLICY_LINES,
|
|
587
592
|
"",
|
|
588
593
|
...EVIDENCE_WORKFLOW_POLICY_LINES,
|
|
@@ -671,10 +676,12 @@ function serializeGeminiStartupBlock(newline) {
|
|
|
671
676
|
"reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
|
|
672
677
|
"after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery/ToolSearch",
|
|
673
678
|
"to load that exact tool, then invoke it.",
|
|
674
|
-
"Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute
|
|
675
|
-
"`session_load_context
|
|
676
|
-
"`conversation_id` returned on the `<prism_session />` line for session_save_ledger, session_save_handoff,
|
|
677
|
-
"session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
|
|
679
|
+
"Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute, and do not use",
|
|
680
|
+
"`session_load_context` in place of it. If discovery or invocation fails, report `Prism startup failure` and stop.",
|
|
681
|
+
"Reuse the `conversation_id` returned on the `<prism_session />` line for session_save_ledger, session_save_handoff,",
|
|
682
|
+
"and session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
|
|
683
|
+
"",
|
|
684
|
+
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
678
685
|
"",
|
|
679
686
|
...LOCAL_FIRST_POLICY_LINES,
|
|
680
687
|
"",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical context-recovery contract shared by every host instruction file.
|
|
3
|
+
*
|
|
4
|
+
* Startup is one `session_bootstrap` call. A later save can still be refused
|
|
5
|
+
* with `context_not_loaded`, and the refusal names one remedy; the host block
|
|
6
|
+
* must say the same thing instead of forbidding it. Keep this host-neutral: the
|
|
7
|
+
* Claude Code, Gemini CLI and Codex blocks splice these lines unchanged, and
|
|
8
|
+
* the server instructions, tool descriptions and refusal text state the same
|
|
9
|
+
* rule in their own words (pinned by tests/startup-recovery-contract.test.ts).
|
|
10
|
+
*/
|
|
11
|
+
export const CONTEXT_RECOVERY_POLICY_LINES = [
|
|
12
|
+
"## Prism context recovery",
|
|
13
|
+
"Startup is the one turn-one `session_bootstrap` call. Prism can still refuse `session_save_ledger` or",
|
|
14
|
+
"`session_save_handoff` with `context_not_loaded`: it has no record that this conversation loaded that project",
|
|
15
|
+
"(after a restart, a long idle gap, or for a project startup did not load). That is a recovery step, not a second",
|
|
16
|
+
"startup. Call `session_load_context` with the same `project` and the same `conversation_id` as the refused save",
|
|
17
|
+
"(the refusal prints the exact call; discover the tool the same way if it is deferred), then retry the save once.",
|
|
18
|
+
"Do not repeat `session_bootstrap` or print a startup display for it. If the reload or the retry fails, tell the",
|
|
19
|
+
"user; a local note is a fallback after that, never a substitute for the reload.",
|
|
20
|
+
];
|
|
21
|
+
export const CONTEXT_RECOVERY_POLICY_TEXT = CONTEXT_RECOVERY_POLICY_LINES.join(" ");
|
package/dist/server.js
CHANGED
|
@@ -361,8 +361,10 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
|
|
|
361
361
|
`Emit no preamble. Print the complete tool result verbatim as the entire first-turn startup display, before any optional ` +
|
|
362
362
|
`answer. Do not summarize, paraphrase, rename headings, reformat, or omit any returned section. Preserve its order and ` +
|
|
363
363
|
`line content. For a greeting-only prompt, stop after the verbatim startup display. ` +
|
|
364
|
-
`Do not substitute session_load_context while session_bootstrap is available
|
|
365
|
-
`
|
|
364
|
+
`Do not substitute session_load_context for the startup call while session_bootstrap is available. Use ` +
|
|
365
|
+
`session_load_context to recover when a save is refused with context_not_loaded (pass that save's project ` +
|
|
366
|
+
`and conversation_id, then retry the save once; recovery is not a second startup), for an explicit project ` +
|
|
367
|
+
`reload, or as an older-server fallback. ` +
|
|
366
368
|
`Use session_save_ledger to log completed work and session_save_handoff to preserve state for the next session. ` +
|
|
367
369
|
`Reuse the conversation_id from session_bootstrap's <prism_session /> line for those saves and for ` +
|
|
368
370
|
`session_detect_drift, the 60-minute goal-alignment drift check. Do not add the id to the visible greeting.\n\n` +
|
|
@@ -84,24 +84,75 @@ export function markContextLoaded(conversationId, project, boundariesVersion) {
|
|
|
84
84
|
s.boundariesVersion = boundariesVersion;
|
|
85
85
|
lastSeenConversationId = conversationId;
|
|
86
86
|
}
|
|
87
|
+
const ENFORCED = " (Enforced server-side — applies to every host.)";
|
|
88
|
+
/**
|
|
89
|
+
* The one remedy every refusal names, so the three variants cannot drift apart
|
|
90
|
+
* and the host instruction blocks (src/contextRecoveryPolicy.ts), the server
|
|
91
|
+
* instructions and the tool descriptions can say the same thing. A block that
|
|
92
|
+
* forbids the call a refusal asks for leaves an agent with no way forward
|
|
93
|
+
* (tests/startup-recovery-contract.test.ts pins every surface).
|
|
94
|
+
*/
|
|
95
|
+
const CONTEXT_RECOVERY = " To recover, call session_load_context with the same project and the same conversation_id you " +
|
|
96
|
+
"passed to this save, then retry the save once. You do not need to repeat session_bootstrap (it " +
|
|
97
|
+
"reloads only the dashboard Auto-Load projects and reprints the startup display). A recovery load " +
|
|
98
|
+
"is not a second startup. If the retry is refused too, stop and tell the user.";
|
|
87
99
|
function contextNotLoadedError(project) {
|
|
88
100
|
const projectNote = project
|
|
89
101
|
? " the requested project was not loaded for this conversation."
|
|
90
|
-
: "";
|
|
102
|
+
: " no context is registered for this conversation.";
|
|
103
|
+
return {
|
|
104
|
+
blocked: true,
|
|
105
|
+
error: "context_not_loaded:" + projectNote + CONTEXT_RECOVERY +
|
|
106
|
+
" This project-scoped tool needs confirmed working context to act correctly." + ENFORCED,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A save whose conversation_id is the empty string can never be recovered by
|
|
111
|
+
* "the same conversation_id you passed": the load handler registers nothing for
|
|
112
|
+
* an empty id. Say what is actually wrong and where the real id comes from.
|
|
113
|
+
*/
|
|
114
|
+
function emptyConversationIdError() {
|
|
91
115
|
return {
|
|
92
116
|
blocked: true,
|
|
93
|
-
error: "context_not_loaded:
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
117
|
+
error: "context_not_loaded: conversation_id is empty, so no context can be registered for it." +
|
|
118
|
+
" Pass this conversation's conversation_id (the value on session_bootstrap's <prism_session /> line)," +
|
|
119
|
+
" call session_load_context with that conversation_id and this project, then retry the save once." +
|
|
120
|
+
" If the retry is refused too, stop and tell the user." + ENFORCED,
|
|
97
121
|
};
|
|
98
122
|
}
|
|
99
123
|
function contextExpiredError() {
|
|
100
124
|
return {
|
|
101
125
|
blocked: true,
|
|
102
|
-
error: "context_not_loaded: session expired (6 h TTL).
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
error: "context_not_loaded: session expired (6 h TTL)." + CONTEXT_RECOVERY + ENFORCED,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Longest project or conversation_id a refusal echoes back. A longer value gets
|
|
131
|
+
* the remedy without a literal call: a clipped value would register the wrong
|
|
132
|
+
* project, and the retry would be refused again.
|
|
133
|
+
*/
|
|
134
|
+
const MAX_ECHOED_VALUE = 200;
|
|
135
|
+
/**
|
|
136
|
+
* Print the literal recovery call, built from the arguments the refused save
|
|
137
|
+
* used, so "the same project and conversation_id" cannot be mistyped or
|
|
138
|
+
* replaced by a host session id. Values are JSON-escaped and echoed whole, only
|
|
139
|
+
* to the caller that just sent them; a value too long to echo gets no call.
|
|
140
|
+
*/
|
|
141
|
+
function withExactCall(gate, conversationId, project) {
|
|
142
|
+
if (!gate || !gate.blocked || !conversationId || !project.trim() || !gate.error.endsWith(ENFORCED)) {
|
|
143
|
+
return gate;
|
|
144
|
+
}
|
|
145
|
+
if (project.length > MAX_ECHOED_VALUE || conversationId.length > MAX_ECHOED_VALUE)
|
|
146
|
+
return gate;
|
|
147
|
+
const call = JSON.stringify({
|
|
148
|
+
project,
|
|
149
|
+
conversation_id: conversationId,
|
|
150
|
+
toolAction: "Reload context",
|
|
151
|
+
toolSummary: "Recover from context_not_loaded",
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
blocked: true,
|
|
155
|
+
error: gate.error.slice(0, -ENFORCED.length) + ` Exact call: session_load_context(${call}).` + ENFORCED,
|
|
105
156
|
};
|
|
106
157
|
}
|
|
107
158
|
function hashReceiptScope(kind, value) {
|
|
@@ -196,9 +247,15 @@ export function requireContextLoaded(conversationId) {
|
|
|
196
247
|
* cross-project lookups remain fail-closed.
|
|
197
248
|
*/
|
|
198
249
|
export async function requireContextLoadedForProject(conversationId, project) {
|
|
250
|
+
const gate = await evaluateContextGateForProject(conversationId, project);
|
|
251
|
+
return conversationId === undefined ? gate : withExactCall(gate, conversationId, project);
|
|
252
|
+
}
|
|
253
|
+
async function evaluateContextGateForProject(conversationId, project) {
|
|
199
254
|
if (conversationId === undefined)
|
|
200
255
|
return null;
|
|
201
|
-
if (!conversationId
|
|
256
|
+
if (!conversationId)
|
|
257
|
+
return emptyConversationIdError();
|
|
258
|
+
if (!project.trim())
|
|
202
259
|
return contextNotLoadedError(project || undefined);
|
|
203
260
|
const memoryGate = requireContextLoaded(conversationId);
|
|
204
261
|
const memoryState = sessions.get(conversationId);
|
|
@@ -35,7 +35,7 @@ import { passesClinicalQualityGate, clinicalPlanScaffold, formatClinicalSections
|
|
|
35
35
|
import { applyDeterministicCodingRepairs, buildCodingRepairPrompt, passesCodingQualityGate, } from "../utils/codingQualityPolicy.js";
|
|
36
36
|
import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
|
|
37
37
|
import { callLayer1 as defaultCallLayer1, classifyDeterministicLayer1, keywordBackstop, reservedCategory, MAX_CLASSIFIER_PROMPT_LENGTH, layer1ClassifierContent, secondReadExclusion } from "../utils/layer1.js";
|
|
38
|
-
import { getSecondReadPolicy, getAnswerCheckPolicy } from "../utils/inferencePolicy.js";
|
|
38
|
+
import { getSecondReadPolicy, getAnswerCheckPolicy, getClassifierInputPolicy } from "../utils/inferencePolicy.js";
|
|
39
39
|
import { pseudonymizeForCheck } from "../utils/pseudonymize.js";
|
|
40
40
|
import { answerGroundingBytes, answerGroundingContent, parseGroundingVerdict, arithmeticSlips, arithmeticCorrection, ANSWER_GROUNDING_OUTPUT_TOKENS, ANSWER_GROUNDING_THINK, ANSWER_GROUNDING_THINK_TOKENS, ANSWER_GROUNDING_TIMEOUT_MS, ANSWER_GROUNDING_RETRY_TIMEOUT_MS, ANSWER_GROUNDING_FOLLOW_UP_TOKENS } from "../utils/answerGrounding.js";
|
|
41
41
|
import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics, estimateTokens } from "../utils/inferenceMetrics.js";
|
|
@@ -256,7 +256,7 @@ export function _resetLayer1HistoryCacheForTest() { layer1HistoryCache.clear();
|
|
|
256
256
|
* probe and read races the time left, and no clearance is accepted after it. */
|
|
257
257
|
export const LAYER1_SECOND_READ_MAX_CALLS = 48;
|
|
258
258
|
export const LAYER1_SECOND_READ_DEADLINE_MS = 30_000;
|
|
259
|
-
/** Reads in flight at once: ONE
|
|
259
|
+
/** Reads in flight at once: ONE.
|
|
260
260
|
* Two at once made each classifier read about twice as slow and some reads
|
|
261
261
|
* abort at the classifier's first-attempt timeout (an aborted read keeps the
|
|
262
262
|
* hedge: refusal or cloud), for little clearance time gained. The per-read
|
|
@@ -269,6 +269,8 @@ function layer1HistoryCached(model, window) {
|
|
|
269
269
|
const hit = layer1HistoryCache.get(key);
|
|
270
270
|
return hit !== undefined && hit.expiresAt > performance.now();
|
|
271
271
|
}
|
|
272
|
+
/** How long the screen waits for the classifier-input policy on its first load. */
|
|
273
|
+
const CLASSIFIER_INPUT_LOAD_MS = 3_000;
|
|
272
274
|
/** Tokens the classifier may generate (callLayer1's num_predict); they share
|
|
273
275
|
* the context with the request. */
|
|
274
276
|
export const LAYER1_CLASSIFIER_OUTPUT_TOKENS = 16;
|
|
@@ -765,6 +767,11 @@ export const PRISM_INFER_TOOL = {
|
|
|
765
767
|
"Synalux deterministic route correction. 'local': skips that correction only.",
|
|
766
768
|
default: "auto",
|
|
767
769
|
},
|
|
770
|
+
allow_parallel_calls: {
|
|
771
|
+
type: "boolean",
|
|
772
|
+
description: "Route: keep a reply of several calls only if every call is in allowed_tools.",
|
|
773
|
+
default: false,
|
|
774
|
+
},
|
|
768
775
|
think: {
|
|
769
776
|
type: "boolean",
|
|
770
777
|
description: "<think> reasoning. Default true for chat/code, false for route; better on complex " +
|
|
@@ -772,10 +779,8 @@ export const PRISM_INFER_TOOL = {
|
|
|
772
779
|
},
|
|
773
780
|
strict_entitlements: {
|
|
774
781
|
type: "boolean",
|
|
775
|
-
description: "
|
|
776
|
-
"
|
|
777
|
-
"throw instead of silently applying free clamps. Portal-confirmed free plans and " +
|
|
778
|
-
"unconfigured machines are unaffected.",
|
|
782
|
+
description: "Throw rather than apply assumed free limits when the portal was unreachable " +
|
|
783
|
+
"(source='fallback_free'). Confirmed-free and unconfigured setups are unaffected.",
|
|
779
784
|
default: false,
|
|
780
785
|
},
|
|
781
786
|
escalation: {
|
|
@@ -874,6 +879,8 @@ export function isPrismInferArgs(args) {
|
|
|
874
879
|
if (a.route_guard !== undefined &&
|
|
875
880
|
!["auto", "local"].includes(a.route_guard))
|
|
876
881
|
return false;
|
|
882
|
+
if (a.allow_parallel_calls !== undefined && typeof a.allow_parallel_calls !== "boolean")
|
|
883
|
+
return false;
|
|
877
884
|
if (a.allowed_tools !== undefined) {
|
|
878
885
|
if (!Array.isArray(a.allowed_tools) || a.allowed_tools.length > MAX_ROUTE_TOOLS)
|
|
879
886
|
return false;
|
|
@@ -1491,8 +1498,8 @@ function makeReservedRefusal(verdict, attempts, category = null, cloudWasAllowed
|
|
|
1491
1498
|
});
|
|
1492
1499
|
return new ReservedRefusalError(verdict, attempts, category, cloudWasAllowed);
|
|
1493
1500
|
}
|
|
1494
|
-
/**
|
|
1495
|
-
*
|
|
1501
|
+
/** Server cap on the flattened conversation (`ROLE: content` lines): the
|
|
1502
|
+
* Synalux inference endpoint enforces the same limit. */
|
|
1496
1503
|
export const CLOUD_HISTORY_CAP_BYTES = 32 * 1024;
|
|
1497
1504
|
/** Exported for tests: the cap check must be provable without a portal. */
|
|
1498
1505
|
export async function callSynaluxInference(prompt, maxTokens, timeoutMs, opts) {
|
|
@@ -1878,6 +1885,7 @@ export async function runInfer(args, deps) {
|
|
|
1878
1885
|
"Retry, or drop strict_entitlements to accept free clamps.");
|
|
1879
1886
|
}
|
|
1880
1887
|
const mode = args.mode ?? "route";
|
|
1888
|
+
const gateOptions = { allowParallelCalls: mode === "route" && args.allow_parallel_calls === true };
|
|
1881
1889
|
// Model choice belongs here—not in session_task_route—because this layer
|
|
1882
1890
|
// owns every viability input and the explicit caller override contract.
|
|
1883
1891
|
const requestedCeiling = resolveRequestedModelCeiling(args);
|
|
@@ -2156,10 +2164,15 @@ export async function runInfer(args, deps) {
|
|
|
2156
2164
|
// kept: reserved and uncertain fail closed for text, error follows
|
|
2157
2165
|
// the single-prompt error path), then each turn and the prompt in
|
|
2158
2166
|
// context (raise only) — see below.
|
|
2167
|
+
// The account's classifier-input policy; without one the classifier
|
|
2168
|
+
// reads the prompt as written.
|
|
2169
|
+
const classifierInput = await (deps.classifierInputPolicy ?? (() => getClassifierInputPolicy({ deadlineMs: CLASSIFIER_INPUT_LOAD_MS })))().catch(() => null);
|
|
2159
2170
|
let l1;
|
|
2160
2171
|
if (!args.messages?.length) {
|
|
2161
|
-
// Single turn: the
|
|
2162
|
-
l1 =
|
|
2172
|
+
// Single turn: one call; the classifier-input policy is passed when there is one.
|
|
2173
|
+
l1 = classifierInput
|
|
2174
|
+
? await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { classifierInput })
|
|
2175
|
+
: await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages);
|
|
2163
2176
|
if (l1 !== "OBVIOUS_NOT_RESERVED")
|
|
2164
2177
|
l1Layer = "prompt";
|
|
2165
2178
|
}
|
|
@@ -2250,7 +2263,7 @@ export async function runInfer(args, deps) {
|
|
|
2250
2263
|
// (review round 19: skipping it there bypassed that floor).
|
|
2251
2264
|
const promptFastPath = promptRoutine && args.prompt.length <= MAX_CLASSIFIER_PROMPT_LENGTH && (resolvedImages?.length ?? 0) === 0;
|
|
2252
2265
|
if (l1 !== "OBVIOUS_RESERVED" && !promptFastPath) {
|
|
2253
|
-
l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false }), "prompt");
|
|
2266
|
+
l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false, ...(classifierInput ? { classifierInput } : {}) }), "prompt");
|
|
2254
2267
|
}
|
|
2255
2268
|
// 3. Context, raise only: one window per turn and one for the
|
|
2256
2269
|
// prompt (see contextWindows), cached like any window. Skipped
|
|
@@ -2844,7 +2857,7 @@ export async function runInfer(args, deps) {
|
|
|
2844
2857
|
let { stripped, thinkOnly } = stripThink(result.text);
|
|
2845
2858
|
let output = stripped;
|
|
2846
2859
|
// Quality gate — all modes. Route uses mode-aware empty floor (length===0).
|
|
2847
|
-
let gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
|
|
2860
|
+
let gate = passesQualityGate(output, thinkOnly, result.doneReason, mode, gateOptions);
|
|
2848
2861
|
if (gate.pass && mode === "code") {
|
|
2849
2862
|
gate = passesCodingQualityGate(args.prompt, output);
|
|
2850
2863
|
}
|
|
@@ -2883,7 +2896,7 @@ export async function runInfer(args, deps) {
|
|
|
2883
2896
|
const retried = await deps.callLocal(deps.ollamaUrl, ollamaName, args.prompt, effectiveSystem, tierTokens, temperature, timeout, false, resolvedImages, ...historyArgs(args));
|
|
2884
2897
|
if (retried.ok) {
|
|
2885
2898
|
const retriedStrip = stripThink(retried.text);
|
|
2886
|
-
const retriedGate = passesQualityGate(retriedStrip.stripped, retriedStrip.thinkOnly, retried.doneReason, mode);
|
|
2899
|
+
const retriedGate = passesQualityGate(retriedStrip.stripped, retriedStrip.thinkOnly, retried.doneReason, mode, gateOptions);
|
|
2887
2900
|
// Keep the retry only if it is actually better — a retry that
|
|
2888
2901
|
// truncates too must not overwrite the original with a
|
|
2889
2902
|
// shorter fragment.
|
|
@@ -2913,7 +2926,7 @@ export async function runInfer(args, deps) {
|
|
|
2913
2926
|
const deterministicRepair = applyDeterministicCodingRepairs(output, failedReason);
|
|
2914
2927
|
if (deterministicRepair.changes.length > 0) {
|
|
2915
2928
|
output = deterministicRepair.output;
|
|
2916
|
-
gate = passesQualityGate(output, false, result.doneReason, mode);
|
|
2929
|
+
gate = passesQualityGate(output, false, result.doneReason, mode, gateOptions);
|
|
2917
2930
|
if (gate.pass) {
|
|
2918
2931
|
gate = passesCodingQualityGate(args.prompt, output);
|
|
2919
2932
|
}
|
|
@@ -3181,6 +3194,7 @@ async function applyVerification(draft, args, deps, partial) {
|
|
|
3181
3194
|
const mode = args.mode ?? "route";
|
|
3182
3195
|
if (mode === "route") {
|
|
3183
3196
|
const allowedTools = new Set(args.allowed_tools ?? DEFAULT_PRISM_ROUTE_TOOLS);
|
|
3197
|
+
const contractOptions = { allowParallel: args.allow_parallel_calls === true };
|
|
3184
3198
|
const parsed = parseRouteOutput(draft);
|
|
3185
3199
|
const shouldUsePortal = args.route_guard !== "local" &&
|
|
3186
3200
|
partial.plan !== "free" &&
|
|
@@ -3198,7 +3212,7 @@ async function applyVerification(draft, args, deps, partial) {
|
|
|
3198
3212
|
});
|
|
3199
3213
|
const portalOutcome = validatePortalRouteGuardOutcome(untrustedPortalOutcome, draft, allowedTools, args.prompt);
|
|
3200
3214
|
if (!portalOutcome) {
|
|
3201
|
-
const localCheck = applyLocalRouteContract(draft, allowedTools);
|
|
3215
|
+
const localCheck = applyLocalRouteContract(draft, allowedTools, contractOptions);
|
|
3202
3216
|
routeGuard = {
|
|
3203
3217
|
...localCheck,
|
|
3204
3218
|
source: "local_fallback",
|
|
@@ -3220,7 +3234,7 @@ async function applyVerification(draft, args, deps, partial) {
|
|
|
3220
3234
|
}
|
|
3221
3235
|
}
|
|
3222
3236
|
catch (error) {
|
|
3223
|
-
const localFallback = applyLocalRouteContract(draft, allowedTools);
|
|
3237
|
+
const localFallback = applyLocalRouteContract(draft, allowedTools, contractOptions);
|
|
3224
3238
|
routeGuard = {
|
|
3225
3239
|
...localFallback,
|
|
3226
3240
|
source: "local_fallback",
|
|
@@ -3239,7 +3253,7 @@ async function applyVerification(draft, args, deps, partial) {
|
|
|
3239
3253
|
}
|
|
3240
3254
|
}
|
|
3241
3255
|
else {
|
|
3242
|
-
routeGuard = applyLocalRouteContract(draft, allowedTools);
|
|
3256
|
+
routeGuard = applyLocalRouteContract(draft, allowedTools, contractOptions);
|
|
3243
3257
|
}
|
|
3244
3258
|
routedDraft = routeGuard.output;
|
|
3245
3259
|
}
|
|
@@ -4,7 +4,10 @@ export const SESSION_SAVE_LEDGER_TOOL = {
|
|
|
4
4
|
description: "Save an immutable session log entry to the session ledger. " +
|
|
5
5
|
"Use this at the END of each work session to record what was accomplished. " +
|
|
6
6
|
"The ledger is append-only — entries cannot be updated or deleted. " +
|
|
7
|
-
"This creates a permanent audit trail of all agent work sessions."
|
|
7
|
+
"This creates a permanent audit trail of all agent work sessions. " +
|
|
8
|
+
"The save is refused with context_not_loaded until session_bootstrap or session_load_context has loaded " +
|
|
9
|
+
"this exact project for this conversation_id; on that refusal call session_load_context with the same " +
|
|
10
|
+
"project and conversation_id, then retry the save once.",
|
|
8
11
|
inputSchema: {
|
|
9
12
|
type: "object",
|
|
10
13
|
properties: {
|
|
@@ -60,7 +63,11 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
|
|
|
60
63
|
"Pass expected_version to enable concurrency control.\n\n" +
|
|
61
64
|
"**v0.4.0 OCC**: If you received a version number from session_load_context, " +
|
|
62
65
|
"/resume_session prompt, or memory resource attachment, you MUST pass it as " +
|
|
63
|
-
"expected_version to prevent overwriting another session's changes
|
|
66
|
+
"expected_version to prevent overwriting another session's changes.\n\n" +
|
|
67
|
+
"Pass the same conversation_id as session_save_ledger: the save is refused with context_not_loaded until " +
|
|
68
|
+
"session_bootstrap or session_load_context has loaded this exact project for it; on that refusal call " +
|
|
69
|
+
"session_load_context with the same project and conversation_id, then retry the save once, passing the " +
|
|
70
|
+
"version it shows as expected_version.",
|
|
64
71
|
inputSchema: {
|
|
65
72
|
type: "object",
|
|
66
73
|
properties: {
|
|
@@ -101,7 +108,7 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
|
|
|
101
108
|
},
|
|
102
109
|
conversation_id: {
|
|
103
110
|
type: "string",
|
|
104
|
-
description: "Optional. Session key for this conversation (same id used in session_load_context). When provided, the server verifies that session_load_context
|
|
111
|
+
description: "Optional. Session key for this conversation (same id used in session_load_context). When provided, the server verifies that session_bootstrap or session_load_context loaded this exact project for this conversation before accepting the write.",
|
|
105
112
|
},
|
|
106
113
|
},
|
|
107
114
|
required: ["project"],
|
|
@@ -111,7 +118,9 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
|
|
|
111
118
|
export const SESSION_LOAD_CONTEXT_TOOL = {
|
|
112
119
|
name: "session_load_context",
|
|
113
120
|
description: "Load session context for a project using progressive context loading. " +
|
|
114
|
-
"Use this
|
|
121
|
+
"Use this to recover when session_save_ledger or session_save_handoff is refused with context_not_loaded " +
|
|
122
|
+
"(pass that save's project and the same conversation_id, then retry the save once; a recovery load is not a " +
|
|
123
|
+
"second startup), for an explicit project reload, or as a startup fallback only when session_bootstrap is unavailable. " +
|
|
115
124
|
"When session_bootstrap is available, do not substitute this tool for the first-turn bootstrap. " +
|
|
116
125
|
"Three levels available:\n" +
|
|
117
126
|
"- **quick**: Just the latest project state — keywords and open TODOs (~50 tokens)\n" +
|
|
@@ -148,7 +157,7 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
|
|
|
148
157
|
},
|
|
149
158
|
conversation_id: {
|
|
150
159
|
type: "string",
|
|
151
|
-
description: "Optional.
|
|
160
|
+
description: "Optional for a plain read. Pass it whenever this load is meant to unlock saves (startup fallback, or recovery from context_not_loaded), using the same conversation_id as the refused or upcoming save. When provided, marks this project as context-loaded server-side so project-scoped saves are accepted; without it nothing is registered.",
|
|
152
161
|
},
|
|
153
162
|
prompt: {
|
|
154
163
|
type: "string",
|
|
@@ -209,7 +218,7 @@ export const SESSION_BOOTSTRAP_TOOL = {
|
|
|
209
218
|
"before any user-facing response, passing the user's verbatim first message as {prompt: \"<first user message>\"}. " +
|
|
210
219
|
"The prompt is matched against prompt_keywords ON-DEVICE to load symptom-triggered skills on turn one; it is used " +
|
|
211
220
|
"for routing only and never leaves the machine. Pass {} only when there is no user message. " +
|
|
212
|
-
"Do not substitute session_load_context
|
|
221
|
+
"Do not substitute session_load_context for this startup call. " +
|
|
213
222
|
"This starts a Prism-backed conversation without host hooks. " +
|
|
214
223
|
"Prism reads the dashboard's Auto-Load Projects, Context Depth (quick/standard/deep), developer name, and default role, " +
|
|
215
224
|
"then returns the greeting and correctly scoped prior-session context. Emit no preamble. Print the complete tool result " +
|
|
@@ -217,7 +226,10 @@ export const SESSION_BOOTSTRAP_TOOL = {
|
|
|
217
226
|
"headings, reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, " +
|
|
218
227
|
"stop after the verbatim startup display. Do not guess or pass a project or depth. Prism returns a stable " +
|
|
219
228
|
"conversation_id on the trailing <prism_session /> line; reuse it for session_save_ledger, session_save_handoff, and " +
|
|
220
|
-
"session_detect_drift throughout this conversation without adding it to the visible greeting."
|
|
229
|
+
"session_detect_drift throughout this conversation without adding it to the visible greeting. " +
|
|
230
|
+
"This is the first-turn startup call only: if a later save is refused with context_not_loaded, recover with " +
|
|
231
|
+
"session_load_context for that save's project and conversation_id, then retry the save once, rather than " +
|
|
232
|
+
"repeating this startup display.",
|
|
221
233
|
annotations: {
|
|
222
234
|
readOnlyHint: true,
|
|
223
235
|
destructiveHint: false,
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* regexes match, and the regexes are already public. Sending the raw first
|
|
14
14
|
* message bought nothing a local match could not compute — and free-tier
|
|
15
15
|
* callers paid that privacy cost for literally zero routing benefit, since
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
16
|
+
* the server returns no prompt-matched skills to the free tier. Paid skill
|
|
17
|
+
* CONTENT stays gated server-side at
|
|
18
18
|
* /api/v1/prism/skill-manifest, which this change does not touch.
|
|
19
19
|
*
|
|
20
20
|
* Cache: portal keyed on (project,role) — no longer per-prompt, which never
|
|
@@ -464,8 +464,8 @@ export function stripQuotedEvidenceForRouting(prompt, promptKeywords = {}) {
|
|
|
464
464
|
return units.join('');
|
|
465
465
|
}
|
|
466
466
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
467
|
+
* Port of the server resolver's prompt-matching step and the sort that
|
|
468
|
+
* follows it. Parity is the whole point: any divergence silently changes
|
|
469
469
|
* which skills load. Do not "improve" this — the reference implementation and
|
|
470
470
|
* a scenario-level parity test both pin it.
|
|
471
471
|
*
|
|
@@ -14,9 +14,9 @@ import { debugLog } from "./logger.js";
|
|
|
14
14
|
import { resolvePortalBaseUrl, usablePortalKey } from "./synaluxSearch.js";
|
|
15
15
|
/** What a host with NO portal (unconfigured), a portal that says nothing
|
|
16
16
|
* (older deployment), or an assumed-free fallback gets: OFF. Multi-turn
|
|
17
|
-
* needs a Synalux account, and a free one is enough
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* needs a Synalux account, and a free one is enough: the answer check it
|
|
18
|
+
* depends on is served per account. The caps here are what an account gets
|
|
19
|
+
* when the portal omits them. */
|
|
20
20
|
export const DEFAULT_MULTI_TURN = { enabled: false, max_turns: 12, max_chars: 32_000 };
|
|
21
21
|
/** Structural ceiling no plan can exceed: the portal's own inference route
|
|
22
22
|
* takes at most 50 messages INCLUDING the current turn appended on
|
|
@@ -38,9 +38,8 @@ export function multiTurnPolicy(ent) {
|
|
|
38
38
|
}
|
|
39
39
|
// ── Free-tier defaults (no auth) ──────────────────────────────────
|
|
40
40
|
/** No account: everything local, with no cap on the model the user's own
|
|
41
|
-
* machine can run
|
|
42
|
-
*
|
|
43
|
-
* one is enough. */
|
|
41
|
+
* machine can run. Anything that needs Synalux (multi-turn with the answer
|
|
42
|
+
* check, cloud answers) needs an account; a free one is enough. */
|
|
44
43
|
export const FREE_ENTITLEMENTS = {
|
|
45
44
|
plan: "free",
|
|
46
45
|
model_ceiling: "27b",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Policies
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Policies on-device features run with, served by Synalux to plans with
|
|
3
|
+
* multi-turn: the second read's exclusion policy (the conversations the 9b may
|
|
4
|
+
* not re-read after a 4b hedge), the answer check's rules, and the screen's
|
|
5
|
+
* classifier-input policy (layer1.ts classifierCopy). Each
|
|
5
6
|
* release accepts exactly one artifact of each, pinned by its SHA-256, so the
|
|
6
7
|
* policy a client runs is the one it was released and validated with.
|
|
7
8
|
*
|
|
@@ -11,7 +12,8 @@
|
|
|
11
12
|
* kept. Anything but the pinned, well-formed artifact is no policy: with
|
|
12
13
|
* no second-read policy the second read does not run (the hedge stands); with
|
|
13
14
|
* no answer-check policy a local answer to a conversation is unchecked (cloud,
|
|
14
|
-
* else withheld)
|
|
15
|
+
* else withheld); with no classifier-input policy the classifier reads the
|
|
16
|
+
* request as written.
|
|
15
17
|
*/
|
|
16
18
|
import { createHash } from "node:crypto";
|
|
17
19
|
import { PRISM_SYNALUX_BASE_URL } from "../config.js";
|
|
@@ -25,10 +27,17 @@ export const SECOND_READ_POLICY_EVALUATOR = "second-read-exclusion/1";
|
|
|
25
27
|
export const ANSWER_CHECK_POLICY_SHA256 = "ba12ab1f6858b68ed36b7c0551aa3381ffb45b6123eb0aacd09c9316efd27993";
|
|
26
28
|
/** The mechanism this client implements (answerGrounding.ts, groundAnswer). */
|
|
27
29
|
export const ANSWER_CHECK_POLICY_EVALUATOR = "answer-check/1";
|
|
30
|
+
/** The classifier-input artifact this release runs. */
|
|
31
|
+
export const CLASSIFIER_INPUT_POLICY_SHA256 = "6b215f9af8cb94c9467852d6bd20f93c3a5c33dd35c649bee77e5f69e0abe4b1";
|
|
32
|
+
/** The mechanism this client implements (layer1.ts classifierCopy). */
|
|
33
|
+
export const CLASSIFIER_INPUT_POLICY_EVALUATOR = "classifier-input/1";
|
|
28
34
|
const MAX_ARTIFACT_BYTES = 64 * 1024;
|
|
29
35
|
const MAX_PATTERN_CHARS = 1_024;
|
|
30
36
|
const MIN_OPERATIONAL_TERMS = 8;
|
|
31
37
|
const MIN_DEPLOY_DECISION = 2;
|
|
38
|
+
const MIN_DROP_WORDS = 8;
|
|
39
|
+
const MAX_WORD_CHARS = 40;
|
|
40
|
+
const MAX_REQUIRED_GROUPS = 4;
|
|
32
41
|
/** A list longer than this is not a policy this client was released with. */
|
|
33
42
|
const MAX_LIST_ENTRIES = 1_000;
|
|
34
43
|
/** A group whose body repeats may be repeated at most this many times. */
|
|
@@ -274,6 +283,66 @@ export function parseAnswerCheckPolicy(bytes, expectSha256 = ANSWER_CHECK_POLICY
|
|
|
274
283
|
return null;
|
|
275
284
|
}
|
|
276
285
|
}
|
|
286
|
+
/** The classifier-input policy from the artifact's exact bytes, or null for
|
|
287
|
+
* anything but the expected artifact: another hash, schema or evaluator, a
|
|
288
|
+
* word list below its floor or with an entry that is not one lowercase word,
|
|
289
|
+
* no required group or a group or qualifier naming an unlisted word, no
|
|
290
|
+
* words a kept sentence must offer, a token
|
|
291
|
+
* pattern that is oversized, refers back, repeats a varying group or does not
|
|
292
|
+
* compile. */
|
|
293
|
+
export function parseClassifierInputPolicy(bytes, expectSha256 = CLASSIFIER_INPUT_POLICY_SHA256) {
|
|
294
|
+
if (Buffer.byteLength(bytes, "utf8") > MAX_ARTIFACT_BYTES)
|
|
295
|
+
return null;
|
|
296
|
+
if (sha256(bytes) !== expectSha256)
|
|
297
|
+
return null;
|
|
298
|
+
let a;
|
|
299
|
+
try {
|
|
300
|
+
a = JSON.parse(bytes);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const art = a;
|
|
306
|
+
if (art?.schema !== 1 || art.evaluator !== CLASSIFIER_INPUT_POLICY_EVALUATOR || typeof art.classifier_input !== "object" || art.classifier_input === null)
|
|
307
|
+
return null;
|
|
308
|
+
const s = art.classifier_input;
|
|
309
|
+
const words = s.drop_sentence_words;
|
|
310
|
+
const word = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_WORD_CHARS && v === v.toLowerCase() && !/\s/.test(v);
|
|
311
|
+
if (!Array.isArray(words) || words.length < MIN_DROP_WORDS || words.length > MAX_LIST_ENTRIES || !words.every(word))
|
|
312
|
+
return null;
|
|
313
|
+
const listed = new Set(words);
|
|
314
|
+
const subset = (v) => Array.isArray(v) && v.length > 0 && v.length <= MAX_LIST_ENTRIES && v.every(w => typeof w === "string" && listed.has(w));
|
|
315
|
+
const groups = s.require_each;
|
|
316
|
+
if (!Array.isArray(groups) || groups.length === 0 || groups.length > MAX_REQUIRED_GROUPS || !groups.every(subset))
|
|
317
|
+
return null;
|
|
318
|
+
const after = s.only_after ?? {};
|
|
319
|
+
if (typeof after !== "object" || after === null || Array.isArray(after))
|
|
320
|
+
return null;
|
|
321
|
+
const afterEntries = Object.entries(after);
|
|
322
|
+
if (!afterEntries.every(([w, prev]) => listed.has(w) && subset(prev)))
|
|
323
|
+
return null;
|
|
324
|
+
const needed = s.kept_needs_one_of;
|
|
325
|
+
if (!Array.isArray(needed) || needed.length === 0 || needed.length > MAX_LIST_ENTRIES || !needed.every(word))
|
|
326
|
+
return null;
|
|
327
|
+
const tokenPattern = (v) => v === undefined || (typeof v === "string" && v.length > 0 && v.length <= MAX_PATTERN_CHARS && !/\\[1-9]|\\k</.test(v) && !hasNestedRepetition(v));
|
|
328
|
+
const also = s.also_match, afterPattern = s.only_after_pattern;
|
|
329
|
+
if (!tokenPattern(also) || !tokenPattern(afterPattern))
|
|
330
|
+
return null;
|
|
331
|
+
try {
|
|
332
|
+
// The client sets the flags (none); the artifact supplies the source only.
|
|
333
|
+
return {
|
|
334
|
+
dropWords: listed,
|
|
335
|
+
requireEach: groups.map(g => new Set(g)),
|
|
336
|
+
onlyAfter: new Map(afterEntries.map(([w, prev]) => [w, new Set(prev)])),
|
|
337
|
+
onlyAfterPattern: typeof afterPattern === "string" ? new RegExp(afterPattern) : null,
|
|
338
|
+
alsoMatch: typeof also === "string" ? new RegExp(also) : null,
|
|
339
|
+
keptNeedsOneOf: new Set(needed),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
277
346
|
/** One pinned artifact: loaded once, shared by concurrent callers, retried
|
|
278
347
|
* after RETRY_AFTER_MS when a load fails, and dropped by reset() when the
|
|
279
348
|
* account changes (a sign-in can switch the account and the portal). A load
|
|
@@ -358,15 +427,19 @@ async function load(o, sha, parse) {
|
|
|
358
427
|
}
|
|
359
428
|
const secondRead = pinned(SECOND_READ_POLICY_SHA256, parseSecondReadPolicy);
|
|
360
429
|
const answerCheck = pinned(ANSWER_CHECK_POLICY_SHA256, parseAnswerCheckPolicy);
|
|
430
|
+
const classifierInput = pinned(CLASSIFIER_INPUT_POLICY_SHA256, parseClassifierInputPolicy);
|
|
361
431
|
/** The pinned second-read policy, or null. */
|
|
362
432
|
export const getSecondReadPolicy = (o = {}) => secondRead.get(o);
|
|
363
433
|
/** The pinned answer-check policy, or null. */
|
|
364
434
|
export const getAnswerCheckPolicy = (o = {}) => answerCheck.get(o);
|
|
365
|
-
/**
|
|
435
|
+
/** The pinned classifier-input policy, or null. */
|
|
436
|
+
export const getClassifierInputPolicy = (o = {}) => classifierInput.get(o);
|
|
437
|
+
/** Drops every cached policy; the next request loads them again. Called
|
|
366
438
|
* when the account changes (dashboard sign-in and sign-out). */
|
|
367
439
|
export function clearInferencePolicies() {
|
|
368
440
|
secondRead.reset();
|
|
369
441
|
answerCheck.reset();
|
|
442
|
+
classifierInput.reset();
|
|
370
443
|
}
|
|
371
444
|
/** Tests only. */
|
|
372
445
|
export function _resetSecondReadPolicyForTest() {
|
package/dist/utils/layer1.js
CHANGED
|
@@ -66,6 +66,62 @@ Answer (one word):`;
|
|
|
66
66
|
export function layer1ClassifierContent(input) {
|
|
67
67
|
return LAYER1_PROMPT.replace("{prompt}", () => input);
|
|
68
68
|
}
|
|
69
|
+
/** Question marks in the scripts prism serves: a sentence with one is never left out. */
|
|
70
|
+
const QUESTION_MARK = new RegExp("[?" + String.fromCharCode(0xff1f, 0xfe56, 0x061f, 0x037e, 0x00bf, 0x203d, 0x2047, 0x2048, 0x2049, 0x2e2e, 0x055e, 0x1367) + "]");
|
|
71
|
+
function words(sentence) {
|
|
72
|
+
return sentence.toLowerCase().split(/[\s,;:()]+/).map((w) => w.replace(TOKEN_EDGE, "")).filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
const TOKEN_EDGE = /^[^\w`'#.+-]+|[^\w`'#+-]+$/g;
|
|
75
|
+
function droppable(sentence, p) {
|
|
76
|
+
// A sentence with a question mark is never left out: it may be what the request asks.
|
|
77
|
+
if (QUESTION_MARK.test(sentence))
|
|
78
|
+
return false;
|
|
79
|
+
const hit = p.requireEach.map(() => false);
|
|
80
|
+
let prev = null;
|
|
81
|
+
for (const token of words(sentence)) {
|
|
82
|
+
const pattern = !!p.alsoMatch?.test(token);
|
|
83
|
+
if (p.dropWords.has(token)) {
|
|
84
|
+
const after = p.onlyAfter.get(token);
|
|
85
|
+
if (after && !(prev !== null && (after.has(prev) || !!p.onlyAfterPattern?.test(prev))))
|
|
86
|
+
return false;
|
|
87
|
+
p.requireEach.forEach((group, i) => { if (group.has(token))
|
|
88
|
+
hit[i] = true; });
|
|
89
|
+
}
|
|
90
|
+
else if (!pattern) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
prev = token;
|
|
94
|
+
}
|
|
95
|
+
return hit.length > 0 && hit.every(Boolean);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The classifier's copy of a request under a classifier-input policy. A
|
|
99
|
+
* sentence the policy allows is left out with one adjacent separator; every
|
|
100
|
+
* other character is kept, and text with nothing to leave out is returned as it is.
|
|
101
|
+
*/
|
|
102
|
+
export function classifierCopy(text, p) {
|
|
103
|
+
// Even indexes are sentences, odd indexes the separators between them.
|
|
104
|
+
const parts = text.split(/((?<=[.!?])[^\S\r\n]+|[\r\n]+)/);
|
|
105
|
+
const keep = parts.map(() => true);
|
|
106
|
+
let dropped = false;
|
|
107
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
108
|
+
if (!droppable(parts[i], p))
|
|
109
|
+
continue;
|
|
110
|
+
keep[i] = false;
|
|
111
|
+
dropped = true;
|
|
112
|
+
if (i > 0 && keep[i - 1])
|
|
113
|
+
keep[i - 1] = false;
|
|
114
|
+
else if (i + 1 < parts.length)
|
|
115
|
+
keep[i + 1] = false;
|
|
116
|
+
}
|
|
117
|
+
if (!dropped)
|
|
118
|
+
return text;
|
|
119
|
+
// The sentences that stay must still say what is asked; otherwise the classifier reads it all.
|
|
120
|
+
if (!parts.some((part, i) => i % 2 === 0 && keep[i] && words(part).some((w) => p.keptNeedsOneOf.has(w))))
|
|
121
|
+
return text;
|
|
122
|
+
const out = parts.filter((_, i) => keep[i]).join("");
|
|
123
|
+
return out.trim() ? out : text;
|
|
124
|
+
}
|
|
69
125
|
const VALID = new Set([
|
|
70
126
|
"OBVIOUS_RESERVED",
|
|
71
127
|
"OBVIOUS_NOT_RESERVED",
|
|
@@ -408,7 +464,8 @@ images, opts) {
|
|
|
408
464
|
// short-circuits to reserved handling.
|
|
409
465
|
return "OBVIOUS_RESERVED";
|
|
410
466
|
}
|
|
411
|
-
const
|
|
467
|
+
const excerpt = oversize ? buildOversizeExcerpt(userPrompt) : userPrompt;
|
|
468
|
+
const classifierInput = opts?.classifierInput && !hasImages ? classifierCopy(excerpt, opts.classifierInput) : excerpt;
|
|
412
469
|
// A SYSTEM baked into the classifier model's Modelfile must not sit in front
|
|
413
470
|
// of LAYER1_PROMPT. prism-coder:4b bakes a tool-routing prompt; with it the
|
|
414
471
|
// private eval gate failed 5/5 runs (two hard negatives refused every run),
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import { parseRouteOutput } from "./routeContract.js";
|
|
1
|
+
import { parseRouteCalls, parseRouteOutput } from "./routeContract.js";
|
|
2
|
+
/** JSON with object keys sorted at every level: the same arguments in any order give one string. */
|
|
3
|
+
function canonicalJson(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
6
|
+
if (value !== null && typeof value === "object") {
|
|
7
|
+
const record = value;
|
|
8
|
+
return `{${Object.keys(record).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(",")}}`;
|
|
9
|
+
}
|
|
10
|
+
return JSON.stringify(value) ?? "null";
|
|
11
|
+
}
|
|
2
12
|
/**
|
|
3
13
|
* Signal 5 — Tool-call bleed: pipe-delimited format leaking into non-tool turns.
|
|
4
14
|
* Matches <|tool_call|> and <|tool_call_end|> only — NOT angle-bracket <tool_call> variants
|
|
@@ -11,8 +21,9 @@ export const TOOL_CALL_BLEED_RE = /<\|tool_call\|>|<\|tool_call_end\|>/;
|
|
|
11
21
|
* @param thinkOnly True if the response was only <think> blocks with no answer
|
|
12
22
|
* @param finishReason Ollama's finish_reason if available (e.g. "length" = truncated)
|
|
13
23
|
* @param mode Inference mode — "route": empty only when blank; "chat": empty only with no letter or digit; "code"/unset: 4 chars or fewer
|
|
24
|
+
* @param options.allowParallelCalls Route mode: a reply of several complete calls is valid
|
|
14
25
|
*/
|
|
15
|
-
export function passesQualityGate(stripped, thinkOnly, finishReason, mode) {
|
|
26
|
+
export function passesQualityGate(stripped, thinkOnly, finishReason, mode, options = {}) {
|
|
16
27
|
// Signal 1: Think-only — model reasoned but produced no answer (check before empty)
|
|
17
28
|
if (thinkOnly) {
|
|
18
29
|
return { pass: false, reason: "think_only" };
|
|
@@ -37,6 +48,22 @@ export function passesQualityGate(stripped, thinkOnly, finishReason, mode) {
|
|
|
37
48
|
if (finishReason === "length") {
|
|
38
49
|
return { pass: false, reason: "hard_truncation" };
|
|
39
50
|
}
|
|
51
|
+
// Several complete calls, when the caller asked for them (route mode). The
|
|
52
|
+
// envelopes repeat by design, so the prose loop checks below would fail any
|
|
53
|
+
// three calls; a loop here is the same call again and again.
|
|
54
|
+
if (mode === "route" && options.allowParallelCalls) {
|
|
55
|
+
const several = parseRouteCalls(stripped);
|
|
56
|
+
if (several.kind === "tool_calls") {
|
|
57
|
+
const seen = new Map();
|
|
58
|
+
for (const c of several.calls) {
|
|
59
|
+
const key = canonicalJson([c.name, c.args]);
|
|
60
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
61
|
+
if ((seen.get(key) ?? 0) >= 3)
|
|
62
|
+
return { pass: false, reason: "loop_detected" };
|
|
63
|
+
}
|
|
64
|
+
return { pass: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
40
67
|
// Signal 5: Tool-call bleed. The pipe envelope is invalid in chat/code,
|
|
41
68
|
// but it is the canonical trained output in route mode. Route mode parses
|
|
42
69
|
// the whole envelope and fails only when the contract is malformed.
|
|
@@ -273,11 +273,76 @@ export function routeServesProse(output) {
|
|
|
273
273
|
const last = ends.reduce((a, b) => (b.at > a.at ? b : a));
|
|
274
274
|
return t.slice(last.at + last.e.length).trim() !== ""; // text after the envelope
|
|
275
275
|
}
|
|
276
|
-
|
|
276
|
+
const OPENERS = [PIPE_START, ANGLE_START];
|
|
277
|
+
const MAX_PARALLEL_CALLS = 64;
|
|
278
|
+
/**
|
|
279
|
+
* A reply of complete tool-call envelopes one after another, with only
|
|
280
|
+
* whitespace between them. Every envelope must be closed and hold a valid
|
|
281
|
+
* call; any text, an unclosed envelope or a bad body makes the whole reply
|
|
282
|
+
* malformed. A single envelope is left to parseRouteOutput.
|
|
283
|
+
*/
|
|
284
|
+
export function parseRouteCalls(output) {
|
|
285
|
+
if (output.length > MAX_ROUTE_OUTPUT_CHARS)
|
|
286
|
+
return { kind: "malformed" };
|
|
287
|
+
const calls = [];
|
|
288
|
+
let rest = output.trim();
|
|
289
|
+
while (rest.length > 0) {
|
|
290
|
+
const opener = OPENERS.find(o => rest.startsWith(o));
|
|
291
|
+
if (!opener || calls.length >= MAX_PARALLEL_CALLS)
|
|
292
|
+
return { kind: "malformed" };
|
|
293
|
+
let end = -1;
|
|
294
|
+
let endToken = "";
|
|
295
|
+
for (const token of END_TOKENS) {
|
|
296
|
+
const at = rest.indexOf(token, opener.length);
|
|
297
|
+
if (at >= 0 && (end < 0 || at < end)) {
|
|
298
|
+
end = at;
|
|
299
|
+
endToken = token;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (end < 0)
|
|
303
|
+
return { kind: "malformed" };
|
|
304
|
+
const body = rest.slice(opener.length, end).trim();
|
|
305
|
+
if (OPENERS.some(o => body.includes(o)))
|
|
306
|
+
return { kind: "malformed" };
|
|
307
|
+
const parsed = parseToolJson(body);
|
|
308
|
+
if (parsed.kind !== "tool_call")
|
|
309
|
+
return { kind: "malformed" };
|
|
310
|
+
calls.push({ name: parsed.name, args: parsed.args });
|
|
311
|
+
rest = rest.slice(end + endToken.length).trim();
|
|
312
|
+
}
|
|
313
|
+
return calls.length > 1 ? { kind: "tool_calls", calls } : { kind: "malformed" };
|
|
314
|
+
}
|
|
315
|
+
export function applyLocalRouteContract(draft, allowedTools = DEFAULT_PRISM_ROUTE_TOOLS, options = {}) {
|
|
277
316
|
const parsed = parseRouteOutput(draft);
|
|
278
317
|
if (parsed.kind === "plain_text") {
|
|
279
318
|
return { output: draft, action: "plain_text", source: "local" };
|
|
280
319
|
}
|
|
320
|
+
if (parsed.kind === "malformed" && options.allowParallel) {
|
|
321
|
+
const several = parseRouteCalls(draft);
|
|
322
|
+
if (several.kind === "tool_calls") {
|
|
323
|
+
if (several.calls.some(c => c.name === "NO_TOOL")) {
|
|
324
|
+
return { output: "NO_TOOL", action: "suppressed", source: "local", reason: "malformed_tool_call" };
|
|
325
|
+
}
|
|
326
|
+
const unadvertised = several.calls.find(c => !allowedTools.has(c.name));
|
|
327
|
+
if (unadvertised) {
|
|
328
|
+
return {
|
|
329
|
+
output: "NO_TOOL",
|
|
330
|
+
action: "suppressed",
|
|
331
|
+
source: "local",
|
|
332
|
+
original_tool: unadvertised.name,
|
|
333
|
+
reason: "unadvertised_tool",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
output: draft,
|
|
338
|
+
action: "preserved",
|
|
339
|
+
source: "local",
|
|
340
|
+
original_tool: several.calls[0].name,
|
|
341
|
+
final_tool: several.calls[0].name,
|
|
342
|
+
calls: several.calls.map(c => c.name),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
281
346
|
if (parsed.kind === "malformed") {
|
|
282
347
|
return {
|
|
283
348
|
output: "NO_TOOL",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.21.
|
|
3
|
+
"version": "20.21.18",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
|
|
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": "node scripts/check-no-private-content.mjs && node scripts/check-publish-clean.mjs && npm run build",
|
|
25
|
+
"prepublishOnly": "node scripts/check-no-private-content.mjs && node scripts/private-identifier-scan.mjs && node scripts/check-publish-clean.mjs && npm run build",
|
|
26
26
|
"lint:dashboard": "node scripts/lint-dashboard-es5.cjs",
|
|
27
27
|
"check:lockfile": "node scripts/check-lockfile-drift.mjs",
|
|
28
28
|
"start": "node dist/server.js",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"overrides": {
|
|
80
80
|
"@hono/node-server": "^2.0.5",
|
|
81
81
|
"body-parser": "^2.3.0",
|
|
82
|
-
"fast-uri": "^3.1.
|
|
82
|
+
"fast-uri": "^3.1.8",
|
|
83
83
|
"hono": "^4.13.5",
|
|
84
84
|
"ip-address": "^10.2.0",
|
|
85
85
|
"protobufjs": "^7.6.5",
|