prism-mcp-server 19.3.1 โ 20.0.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/LICENSE +202 -661
- package/README.md +44 -6
- package/dist/config.js +5 -11
- package/dist/scholar/webScholar.js +1 -1
- package/dist/server.js +34 -0
- package/dist/session/sessionContext.js +85 -26
- package/dist/storage/sqlite.js +1 -0
- package/dist/tools/__tests__/ledgerHandlers.test.js +3 -0
- package/dist/tools/ledgerHandlers.js +50 -103
- package/dist/tools/prismInferHandler.js +2 -2
- package/dist/tools/sessionMemoryDefinitions.js +13 -0
- package/dist/tools/skillRouting.js +102 -135
- package/dist/utils/llm/adapters/voyage.js +29 -106
- package/dist/utils/llm/factory.js +10 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/prism-mcp-server)
|
|
6
6
|
[](https://github.com/modelcontextprotocol/servers)
|
|
7
|
-
[](LICENSE)
|
|
8
8
|
[](https://huggingface.co/dcostenco)
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
@@ -18,6 +18,25 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
+
## What's New in v20
|
|
22
|
+
|
|
23
|
+
### License: AGPL-3.0 โ Apache-2.0
|
|
24
|
+
Prism MCP is now Apache-2.0. The thin-client architecture means all proprietary value (skill resolution, tier gating, billing, cloud inference) lives server-side โ the open client carries no moat to protect. Apache-2.0 removes the enterprise adoption friction that AGPL caused.
|
|
25
|
+
|
|
26
|
+
### Thin Client Architecture
|
|
27
|
+
Skill routing, budget management, and content resolution have moved server-side to the Synalux portal. The MCP client is now a thin API caller โ simpler, smaller, and portable across any host (Claude Code, Gemini, Cursor, autonomous scripts). Offline fallback reads the last successful response from local SQLite.
|
|
28
|
+
|
|
29
|
+
### Clean-Room Voyage AI Adapter
|
|
30
|
+
The Voyage AI embedding adapter was independently reimplemented from the [Voyage API docs](https://docs.voyageai.com/reference/embeddings-api) to ensure 100% project-owned copyright. Default model updated to `voyage-3.5`. See [PROVENANCE.md](./PROVENANCE.md) for details.
|
|
31
|
+
|
|
32
|
+
### Server-Side Drift Detection
|
|
33
|
+
Session drift detection (GATE 5) no longer requires Claude Code hooks. The timer runs server-side per conversation, piggybacked on every MCP tool response. Works for any host.
|
|
34
|
+
|
|
35
|
+
### CLA Requirement
|
|
36
|
+
External contributions now require signing the [Individual CLA](./CLA.md). The CLA check is merge-blocking on the `main` branch.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
21
40
|
## Quickstart
|
|
22
41
|
|
|
23
42
|
The free tier needs no account, no API key, and no cloud. Add the server to your MCP client:
|
|
@@ -85,7 +104,9 @@ Every `prism_infer` call tracks which model handled it (local Ollama vs cloud) a
|
|
|
85
104
|
```
|
|
86
105
|
๐ Inference Metrics (this session):
|
|
87
106
|
Total calls: 12 โ Local: 10 (83%) | Cloud: 2 (17%)
|
|
88
|
-
|
|
107
|
+
Prompt tokens: 7,840 evaluated / 8,420 submitted est.
|
|
108
|
+
Completion tokens: 3,150
|
|
109
|
+
Cloud tokens saved (est.): 11,570 โ token volume handled locally instead of cloud
|
|
89
110
|
Avg latency: 1,240ms
|
|
90
111
|
By model:
|
|
91
112
|
prism-coder:27b: 6 calls, 7,200 tokens, avg 1,800ms
|
|
@@ -93,6 +114,8 @@ Every `prism_infer` call tracks which model handled it (local Ollama vs cloud) a
|
|
|
93
114
|
synalux-27b: 2 calls, 1,500 tokens, avg 1,100ms
|
|
94
115
|
```
|
|
95
116
|
|
|
117
|
+
**Cloud tokens saved** is the honest routing metric โ it accrues only when local Ollama handles a call that would otherwise have gone to Claude or the Synalux portal. A compact version appears inline after every 5th `prism_infer` call: `๐ local 10 (83%) ยท cloud 2 (17%) ยท ~11,570 tok ยท avg 1,240ms ยท 11,570 cloud tok saved`.
|
|
118
|
+
|
|
96
119
|
Local calls use actual Ollama token counts (`prompt_eval_count` / `eval_count` from Ollama); cloud calls use char/4 estimates. Metrics are tracked locally โ no portal dependency, no env vars, works offline. Per-call data is also forwarded to the Synalux portal as best-effort analytics (independent of the display).
|
|
97
120
|
|
|
98
121
|
### Session Drift Detection
|
|
@@ -619,15 +642,30 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
|
|
|
619
642
|
|
|
620
643
|
---
|
|
621
644
|
|
|
622
|
-
## License
|
|
645
|
+
## License & Tiers
|
|
646
|
+
|
|
647
|
+
- **This repository (the Prism MCP client)** is licensed under [Apache-2.0](./LICENSE).
|
|
648
|
+
- **Free tier**: run Prism MCP locally against your own machine. No account required.
|
|
649
|
+
- **Paid tiers**: cloud features (hosted inference cascade, cross-device memory,
|
|
650
|
+
team features) are provided by the Synalux cloud service and governed by the
|
|
651
|
+
Synalux Terms of Service โ they are not part of this repository or its license.
|
|
652
|
+
- Contributions require signing the [CLA](./CLA.md).
|
|
653
|
+
- "Prism" and "Synalux" are trade names of Synalux LLC; the Apache license does
|
|
654
|
+
not grant trademark rights (see ยง6 of the license).
|
|
655
|
+
|
|
656
|
+
### License change (v20)
|
|
657
|
+
|
|
658
|
+
As of this release, prism-mcp is relicensed from AGPL-3.0 to Apache-2.0.
|
|
659
|
+
Prior versions remain under AGPL-3.0. Existing forks retain all rights
|
|
660
|
+
received under the original license.
|
|
623
661
|
|
|
624
662
|
| Product | License |
|
|
625
663
|
|---|---|
|
|
626
|
-
| **prism-mcp-server** (this repo) | [
|
|
664
|
+
| **prism-mcp-server** (this repo) | [Apache-2.0](LICENSE) |
|
|
627
665
|
| **VS Code extension** (synalux-ai.synalux) | BSL-1.1 |
|
|
628
666
|
| **Web IDE** (synalux.ai/coder) | Synalux Terms of Service |
|
|
629
|
-
| **Prism AAC** |
|
|
667
|
+
| **Prism AAC** | Apache-2.0 |
|
|
630
668
|
|
|
631
|
-
|
|
669
|
+
This repository is licensed under Apache-2.0. Cloud features (hosted inference, cross-device memory, team features) are provided by the Synalux cloud service under separate terms.
|
|
632
670
|
|
|
633
671
|
ยฉ 2026 Synalux, LLC.
|
package/dist/config.js
CHANGED
|
@@ -15,9 +15,8 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
* SUPABASE_KEY โ (optional) Your Supabase anon/service key. Enables session memory tools.
|
|
16
16
|
* PRISM_USER_ID โ (optional) Unique tenant ID for multi-user Supabase instances.
|
|
17
17
|
* Defaults to "default". Set per-user in Claude Desktop config.
|
|
18
|
-
* VOYAGE_API_KEY โ (optional) API key for
|
|
19
|
-
*
|
|
20
|
-
* Claude. Get a free key at https://dash.voyageai.com.
|
|
18
|
+
* VOYAGE_API_KEY โ (optional) Voyage AI API key for embeddings.
|
|
19
|
+
* Set embedding_provider=voyage to use. https://dash.voyageai.com
|
|
21
20
|
*
|
|
22
21
|
* If a required key is missing, the process exits immediately.
|
|
23
22
|
* If an optional key is missing, a warning is logged but the server continues
|
|
@@ -64,14 +63,9 @@ export const SEMANTIC_SCHOLAR_API_KEY = process.env.SEMANTIC_SCHOLAR_API_KEY;
|
|
|
64
63
|
if (!BRAVE_ANSWERS_API_KEY && process.env.PRISM_DEBUG_LOGGING === "true") {
|
|
65
64
|
console.error("Warning: BRAVE_ANSWERS_API_KEY environment variable is missing. Brave Answers tool will be unavailable.");
|
|
66
65
|
}
|
|
67
|
-
// โโโ Optional: Voyage AI
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
// alongside Claude. voyage-3 supports 768-dim output via MRL truncation,
|
|
71
|
-
// matching Prism's storage schema for zero-migration drop-in replacement.
|
|
72
|
-
// Without this, VoyageAdapter construction will throw at server start if
|
|
73
|
-
// embedding_provider=voyage is selected.
|
|
74
|
-
export const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;
|
|
66
|
+
// โโโ Optional: Voyage AI Embeddings โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
67
|
+
// Set embedding_provider=voyage to enable. Requires VOYAGE_API_KEY.
|
|
68
|
+
export const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY; // embedding_provider=voyage
|
|
75
69
|
// โโโ Optional: Google Search (Scholar Pipeline Fallback) โโโโโโ
|
|
76
70
|
// Used when Brave or Tavily keys are missing.
|
|
77
71
|
// Requires: Google Custom Search API Key + Search Engine ID (CX).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BRAVE_API_KEY, FIRECRAWL_API_KEY, GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_CX, SEMANTIC_SCHOLAR_API_KEY, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN, PRISM_USER_ID, PRISM_SCHOLAR_TOPICS, PRISM_ENABLE_HIVEMIND } from "../config.js";
|
|
1
|
+
import { BRAVE_API_KEY, FIRECRAWL_API_KEY, GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_CX, SEMANTIC_SCHOLAR_API_KEY, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN, PRISM_USER_ID, PRISM_SCHOLAR_TOPICS, PRISM_ENABLE_HIVEMIND, } from "../config.js";
|
|
2
2
|
import { getStorage } from "../storage/index.js";
|
|
3
3
|
import { debugLog } from "../utils/logger.js";
|
|
4
4
|
import { getLLMProvider } from "../utils/llm/factory.js";
|
package/dist/server.js
CHANGED
|
@@ -729,6 +729,14 @@ export function createServer() {
|
|
|
729
729
|
if (!SESSION_MEMORY_ENABLED)
|
|
730
730
|
throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
|
|
731
731
|
result = await sessionSaveLedgerHandler(args);
|
|
732
|
+
// GATE 5: Reset drift timer โ save_ledger counts as a drift checkpoint
|
|
733
|
+
{
|
|
734
|
+
const cid = args?.conversation_id;
|
|
735
|
+
if (cid) {
|
|
736
|
+
const { noteDriftCheck } = await import("./session/sessionContext.js");
|
|
737
|
+
noteDriftCheck(cid);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
732
740
|
break;
|
|
733
741
|
case "session_save_handoff":
|
|
734
742
|
if (!SESSION_MEMORY_ENABLED)
|
|
@@ -902,6 +910,14 @@ export function createServer() {
|
|
|
902
910
|
if (!SESSION_MEMORY_ENABLED)
|
|
903
911
|
throw new Error("Session memory not configured. Set SUPABASE_URL and SUPABASE_KEY.");
|
|
904
912
|
result = await sessionDetectDriftHandler(args);
|
|
913
|
+
// GATE 5: Reset drift timer โ detect_drift is the canonical check
|
|
914
|
+
{
|
|
915
|
+
const cid = args?.conversation_id;
|
|
916
|
+
if (cid) {
|
|
917
|
+
const { noteDriftCheck } = await import("./session/sessionContext.js");
|
|
918
|
+
noteDriftCheck(cid);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
905
921
|
break;
|
|
906
922
|
case "verify_behavior":
|
|
907
923
|
if (!isVerifyBehaviorArgs(args))
|
|
@@ -1008,6 +1024,24 @@ export function createServer() {
|
|
|
1008
1024
|
}
|
|
1009
1025
|
}
|
|
1010
1026
|
}
|
|
1027
|
+
// โโโ GATE 5: Server-Side Drift Timer Injection โโโ
|
|
1028
|
+
// Piggyback on every prism-mcp tool response: if the session is 60+
|
|
1029
|
+
// minutes old and hasn't run session_detect_drift in the last 60 min,
|
|
1030
|
+
// append a mandatory reminder. This replaces the guard_on_submit.py
|
|
1031
|
+
// hook dependency โ works on any host, not just Claude Code.
|
|
1032
|
+
if (result && !result.isError && Array.isArray(result.content)) {
|
|
1033
|
+
const convId = args?.conversation_id;
|
|
1034
|
+
if (convId) {
|
|
1035
|
+
try {
|
|
1036
|
+
const { getDriftReminder } = await import("./session/sessionContext.js");
|
|
1037
|
+
const reminder = getDriftReminder(convId);
|
|
1038
|
+
if (reminder) {
|
|
1039
|
+
result.content.push({ type: "text", text: reminder });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch { /* getDriftReminder is best-effort โ never block tool responses */ }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1011
1045
|
// Sanitize all text content returning from MCP tools to prevent prompt injection
|
|
1012
1046
|
if (result && Array.isArray(result.content)) {
|
|
1013
1047
|
result.content.forEach((c) => {
|
|
@@ -1,37 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Session state tracking โ connection-scoped, in-process.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This is NOT business logic โ it's MCP connection lifecycle state.
|
|
5
|
+
* Business logic (skill routing, budget tranching, content resolution)
|
|
6
|
+
* lives in the synalux portal at /api/v1/prism/skills.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* mark_loaded.py flipping a pending flag. Neither mechanism runs on a
|
|
12
|
-
* non-Claude host (Gemini, autonomous script, cron job).
|
|
8
|
+
* What stays here (connection-scoped, cannot be portal-side):
|
|
9
|
+
* markContextLoaded / requireContextLoaded โ write-gate for session tools
|
|
10
|
+
* noteInferenceForSession โ telemetry counter
|
|
11
|
+
* drift timer โ connection-scoped GATE 5 enforcement
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* TWO DELIBERATE BOUNDARIES
|
|
19
|
-
* -------------------------
|
|
20
|
-
* 1. This is NOT a safety mechanism. prism_infer's input/output safety gates
|
|
21
|
-
* run unconditionally before and after every model call. A host that never
|
|
22
|
-
* loads context still cannot reach an un-gated model.
|
|
23
|
-
*
|
|
24
|
-
* 2. requireContextLoaded gates only CORRECTNESS-requiring, project-scoped
|
|
25
|
-
* actions (save_ledger, save_handoff) โ tools that act on a specific project
|
|
26
|
-
* and produce wrong results if the agent hasn't confirmed its working context.
|
|
27
|
-
* Do NOT gate prism_infer on this; a host that never loads context must still
|
|
28
|
-
* be able to run inference (safety is already unconditional there).
|
|
29
|
-
*
|
|
30
|
-
* Fail-closed: an unknown or expired conversation_id โ context not loaded.
|
|
13
|
+
* What moved to portal (business logic):
|
|
14
|
+
* Skill routing, budget tranching, content loading, phantom detection,
|
|
15
|
+
* prompt-keyword matching, user-local skill loading, context-discovery.
|
|
31
16
|
*/
|
|
32
17
|
import { BOUNDARIES_VERSION as CURRENT_BOUNDARIES_VERSION } from "../boundaries/boundaries.js";
|
|
33
18
|
const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 h โ conversation-scoped
|
|
34
19
|
const MAX_SESSIONS = 10_000;
|
|
20
|
+
/**
|
|
21
|
+
* Connection-scoped fallback: remember the last conversation_id seen via
|
|
22
|
+
* markContextLoaded so that tools which don't carry conversation_id
|
|
23
|
+
* (e.g. prism_infer) still benefit from drift reminders.
|
|
24
|
+
*/
|
|
25
|
+
let lastSeenConversationId;
|
|
35
26
|
// JS Map preserves insertion order. Touch-on-access (delete + re-insert) keeps
|
|
36
27
|
// the Map ordered LRU-last so eviction can pop the first key in O(1).
|
|
37
28
|
const sessions = new Map();
|
|
@@ -88,6 +79,7 @@ export function markContextLoaded(conversationId, project, boundariesVersion) {
|
|
|
88
79
|
s.contextLoaded = true;
|
|
89
80
|
s.project = project;
|
|
90
81
|
s.boundariesVersion = boundariesVersion;
|
|
82
|
+
lastSeenConversationId = conversationId;
|
|
91
83
|
}
|
|
92
84
|
/**
|
|
93
85
|
* Soft gate for handlers that need project context to be CORRECT (not safe).
|
|
@@ -163,3 +155,70 @@ export function noteInferenceForSession(conversationId, info) {
|
|
|
163
155
|
export function getSessionState(conversationId) {
|
|
164
156
|
return sessions.get(conversationId) ?? null;
|
|
165
157
|
}
|
|
158
|
+
// โโโ GATE 5: Server-Side Drift Timer โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
159
|
+
// These functions track drift detection timing per conversation so the
|
|
160
|
+
// server can inject GATE 5 reminders into tool responses without relying
|
|
161
|
+
// on external hooks (guard_on_submit.py). The server is pull-based โ it
|
|
162
|
+
// can only inject when a tool is called, so we piggyback on every
|
|
163
|
+
// prism-mcp tool response when the timer is overdue.
|
|
164
|
+
const DRIFT_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 60 minutes
|
|
165
|
+
/**
|
|
166
|
+
* Called by sessionLoadContextHandler to mark session start for drift timing.
|
|
167
|
+
*/
|
|
168
|
+
export function noteDriftSessionStart(conversationId) {
|
|
169
|
+
const s = sessions.get(conversationId);
|
|
170
|
+
if (!s)
|
|
171
|
+
return;
|
|
172
|
+
s.driftSessionStart = Date.now();
|
|
173
|
+
// Don't set driftLastCheck โ the first check should happen 60 min after start
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Called after session_detect_drift or session_save_ledger completes
|
|
177
|
+
* to reset the drift timer for this conversation.
|
|
178
|
+
*/
|
|
179
|
+
export function noteDriftCheck(conversationId) {
|
|
180
|
+
const s = sessions.get(conversationId);
|
|
181
|
+
if (!s)
|
|
182
|
+
return;
|
|
183
|
+
s.driftLastCheck = Date.now();
|
|
184
|
+
touch(conversationId, s);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Returns a GATE 5 drift reminder string if 60+ minutes have elapsed
|
|
188
|
+
* since session start AND 60+ minutes since the last drift check.
|
|
189
|
+
* Returns empty string if no reminder is due or if the session is too
|
|
190
|
+
* young (< 60 min).
|
|
191
|
+
*
|
|
192
|
+
* This is called from the common tool response path in server.ts and
|
|
193
|
+
* appended to every prism-mcp tool response when overdue.
|
|
194
|
+
*/
|
|
195
|
+
export function getDriftReminder(conversationId) {
|
|
196
|
+
const effectiveId = conversationId || lastSeenConversationId;
|
|
197
|
+
if (!effectiveId)
|
|
198
|
+
return "";
|
|
199
|
+
const s = sessions.get(effectiveId);
|
|
200
|
+
if (!s || !s.contextLoaded || !s.driftSessionStart)
|
|
201
|
+
return "";
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const sessionAge = now - s.driftSessionStart;
|
|
204
|
+
// Session must be at least 60 minutes old before reminders kick in
|
|
205
|
+
if (sessionAge < DRIFT_CHECK_INTERVAL_MS)
|
|
206
|
+
return "";
|
|
207
|
+
// If a drift check has been done, only remind if 60+ min since last check
|
|
208
|
+
if (s.driftLastCheck) {
|
|
209
|
+
const sinceLastCheck = now - s.driftLastCheck;
|
|
210
|
+
if (sinceLastCheck < DRIFT_CHECK_INTERVAL_MS)
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
// Drift reminder is overdue
|
|
214
|
+
const minutesSinceStart = Math.round(sessionAge / 60_000);
|
|
215
|
+
const minutesSinceCheck = s.driftLastCheck
|
|
216
|
+
? Math.round((now - s.driftLastCheck) / 60_000)
|
|
217
|
+
: minutesSinceStart;
|
|
218
|
+
return (`\n\n[โฐ GATE 5 โ DRIFT CHECK OVERDUE (${minutesSinceCheck} min since last check, session ${minutesSinceStart} min old)]\n` +
|
|
219
|
+
`Long-session drift protocol requires action NOW:\n` +
|
|
220
|
+
`1. Call session_save_ledger โ snapshot current state\n` +
|
|
221
|
+
`2. Call session_detect_drift โ check drift vs original goals\n` +
|
|
222
|
+
`3. If major_drift returned: call session_compact_ledger + reload context\n` +
|
|
223
|
+
`This check is MANDATORY every 60 minutes. Do not skip.`);
|
|
224
|
+
}
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -741,6 +741,7 @@ export class SqliteStorage {
|
|
|
741
741
|
"archived_at", "deleted_at", "is_rollup", "role", "event_type",
|
|
742
742
|
"created_at", "updated_at", "session_date", "importance", "title",
|
|
743
743
|
"agent_name", "last_accessed_at", "confidence_score", "rollup_count",
|
|
744
|
+
"embedding",
|
|
744
745
|
]);
|
|
745
746
|
parsePostgRESTFilters(params) {
|
|
746
747
|
const conditions = [];
|
|
@@ -128,6 +128,9 @@ vi.mock("../../../src/session/sessionContext.js", () => ({
|
|
|
128
128
|
requireContextLoaded: vi.fn(() => null),
|
|
129
129
|
noteInferenceForSession: vi.fn(),
|
|
130
130
|
getSessionState: vi.fn(() => null),
|
|
131
|
+
noteDriftSessionStart: vi.fn(),
|
|
132
|
+
noteDriftCheck: vi.fn(),
|
|
133
|
+
getDriftReminder: vi.fn(() => ""),
|
|
131
134
|
}));
|
|
132
135
|
// Boundaries โ return minimal stubs so load-context tests don't depend on exact text.
|
|
133
136
|
vi.mock("../../../src/boundaries/boundaries.js", () => ({
|
|
@@ -31,7 +31,7 @@ import { getCurrentGitState, getGitDrift } from "../utils/git.js";
|
|
|
31
31
|
import { getSetting, getAllSettings } from "../storage/configStorage.js";
|
|
32
32
|
import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
|
|
33
33
|
import { resolveProject } from "../utils/projectResolver.js";
|
|
34
|
-
import { PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS
|
|
34
|
+
import { PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS } from "../config.js";
|
|
35
35
|
import { captureLocalEnvironment } from "../utils/autoCapture.js";
|
|
36
36
|
import { fireCaptionAsync } from "../utils/imageCaptioner.js";
|
|
37
37
|
import { isSessionSaveLedgerArgs, isSessionSaveHandoffArgs, isSessionLoadContextArgs, isMemoryHistoryArgs, isMemoryCheckoutArgs, // v2.2.0: health check type guard
|
|
@@ -612,7 +612,7 @@ export async function sessionLoadContextHandler(args) {
|
|
|
612
612
|
if (getInferenceSnapshot().totalCalls === 0) {
|
|
613
613
|
resetInferenceMetrics();
|
|
614
614
|
}
|
|
615
|
-
const { project, level = "standard", role, conversation_id: convId } = args;
|
|
615
|
+
const { project, level = "standard", role, conversation_id: convId, prompt } = args;
|
|
616
616
|
// T6 fix: explicit Number() coercion prevents string "2000" from later concatenating instead of adding
|
|
617
617
|
const _maxTokensArg = Number(args.max_tokens);
|
|
618
618
|
const _maxTokensSetting = parseInt(await getSetting("max_tokens", "0"), 10);
|
|
@@ -638,22 +638,32 @@ export async function sessionLoadContextHandler(args) {
|
|
|
638
638
|
if (!data) {
|
|
639
639
|
let freshSkillBlock = "";
|
|
640
640
|
try {
|
|
641
|
-
const {
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
641
|
+
const { resolveSkills: resolveForFresh } = await import("./skillRouting.js");
|
|
642
|
+
const freshResolution = await resolveForFresh(project);
|
|
643
|
+
if (freshResolution.skillBlock) {
|
|
644
|
+
freshSkillBlock = freshResolution.skillBlock;
|
|
645
|
+
}
|
|
646
|
+
// Offline fallback: load protected skills from local DB
|
|
647
|
+
if (freshResolution.isOffline) {
|
|
648
|
+
const protectedNames = ['prime-directive', 'evidence-first-protocol', 'behavioral-verifier', 'occam-razor-protocol', 'session-drift-detection', 'pre-commit-protocol', 'pre-push-audit', 'implementation-integrity-audit', 'bcba_ai_assistant'];
|
|
649
|
+
for (const name of protectedNames) {
|
|
650
|
+
if (freshResolution.names?.includes(name))
|
|
651
|
+
continue;
|
|
652
|
+
const content = await getSetting(`skill:${name}`, "");
|
|
653
|
+
if (content?.trim()) {
|
|
654
|
+
freshSkillBlock += `\n\n[๐ SKILL: ${name}]\n${content.trim()}`;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
648
657
|
}
|
|
649
658
|
}
|
|
650
659
|
catch {
|
|
651
660
|
debugLog(`[session_load_context] Fresh project skill injection failed โ continuing without`);
|
|
652
661
|
}
|
|
653
662
|
if (convId) {
|
|
654
|
-
const { markContextLoaded } = await import("../session/sessionContext.js");
|
|
663
|
+
const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
|
|
655
664
|
const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
|
|
656
665
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
666
|
+
noteDriftSessionStart(convId);
|
|
657
667
|
}
|
|
658
668
|
const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
|
|
659
669
|
const boundariesHeader0 = `# OPERATING BOUNDARIES (v${BV0}) โ enforced server-side, shown for transparency\n` +
|
|
@@ -907,106 +917,41 @@ export async function sessionLoadContextHandler(args) {
|
|
|
907
917
|
debugLog(`[session_load_context] Injecting skill for role="${effectiveRole}" (${skillContent.length} chars)`);
|
|
908
918
|
}
|
|
909
919
|
}
|
|
910
|
-
// โโโ
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
if (SYNALUX_CONFIGURED && storage && typeof storage.fetchSkillContent === "function") {
|
|
925
|
-
const missing = sortedSkills.map(s => s.name).filter(n => !loadedSkills.includes(n));
|
|
926
|
-
synaluxContent = await storage
|
|
927
|
-
.fetchSkillContent(missing).catch(() => ({}));
|
|
928
|
-
debugLog(`[session_load_context] Synalux skill content fetched: ${Object.keys(synaluxContent).join(", ") || "none"}`);
|
|
929
|
-
}
|
|
930
|
-
const SKILL_BLOCK_CAP = 40_000;
|
|
931
|
-
const skippedSkills = [];
|
|
932
|
-
for (const entry of sortedSkills) {
|
|
933
|
-
if (loadedSkills.includes(entry.name))
|
|
934
|
-
continue;
|
|
935
|
-
const content = synaluxContent[entry.name] || await getSetting(`skill:${entry.name}`, "");
|
|
936
|
-
if (!content || !content.trim())
|
|
937
|
-
continue;
|
|
938
|
-
const trimmed = content.trim();
|
|
939
|
-
if (entry.protected) {
|
|
940
|
-
skillBlock += `\n\n[๐ SKILL: ${entry.name}]\n${trimmed}`;
|
|
941
|
-
loadedSkills.push(entry.name);
|
|
942
|
-
skillLoaded = true;
|
|
943
|
-
debugLog(`[session_load_context] Skill "${entry.name}" loaded (protected, p${entry.priority}) [${skillBlock.length} chars]`);
|
|
944
|
-
continue;
|
|
945
|
-
}
|
|
946
|
-
if (skillBlock.length + trimmed.length > SKILL_BLOCK_CAP) {
|
|
947
|
-
skippedSkills.push(entry.name);
|
|
948
|
-
debugLog(`[session_load_context] Skill "${entry.name}" skipped โ would exceed cap (${skillBlock.length}+${trimmed.length} > ${SKILL_BLOCK_CAP})`);
|
|
949
|
-
continue;
|
|
950
|
-
}
|
|
951
|
-
const source = synaluxContent[entry.name] ? "synalux" : "local-platform";
|
|
952
|
-
skillBlock += `\n\n[๐ SKILL: ${entry.name}]\n${trimmed}`;
|
|
953
|
-
loadedSkills.push(entry.name);
|
|
920
|
+
// โโโ All other skills resolved by portal API โโโโโโโโโโโโโโโ
|
|
921
|
+
const { resolveSkills, _setStorage } = await import("./skillRouting.js");
|
|
922
|
+
_setStorage(async (k, v) => { try {
|
|
923
|
+
await storage.setSetting?.(k, v);
|
|
924
|
+
}
|
|
925
|
+
catch { } }, async (k) => { try {
|
|
926
|
+
return await getSetting(k, "");
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return "";
|
|
930
|
+
} });
|
|
931
|
+
const skillResolution = await resolveSkills(project, prompt, effectiveRole);
|
|
932
|
+
if (skillResolution.skillBlock) {
|
|
933
|
+
skillBlock += skillResolution.skillBlock;
|
|
954
934
|
skillLoaded = true;
|
|
955
|
-
|
|
956
|
-
}
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
// write here via dashboard; they CANNOT write to the platform skill: keys.
|
|
961
|
-
if (userLocalPolicy.enabled) {
|
|
962
|
-
const prefix = userLocalPolicy.key_prefix || "user_skill:";
|
|
935
|
+
loadedSkills.push(...(skillResolution.names || []));
|
|
936
|
+
}
|
|
937
|
+
// Offline fallback: last-good skillBlock was already tried by skillRouting.ts.
|
|
938
|
+
// If still offline with no skillBlock, load whatever's in local DB as final resort.
|
|
939
|
+
if (skillResolution.isOffline && !skillResolution.skillBlock) {
|
|
963
940
|
const allSettings = await storage.getAllSettings?.() || {};
|
|
964
941
|
for (const [k, v] of Object.entries(allSettings)) {
|
|
965
|
-
if (!k.startsWith(prefix) || !v)
|
|
966
|
-
continue;
|
|
967
|
-
if (skillBlock.length >= SKILL_BLOCK_CAP)
|
|
968
|
-
break;
|
|
969
|
-
const skillName = k.replace(prefix, "");
|
|
970
|
-
if (loadedSkills.includes(skillName))
|
|
971
|
-
continue;
|
|
972
|
-
const trimmed = v.trim();
|
|
973
|
-
if (skillBlock.length + trimmed.length > SKILL_BLOCK_CAP && loadedSkills.length > 0)
|
|
974
|
-
continue;
|
|
975
|
-
skillBlock += `\n\n[๐ USER SKILL: ${skillName}]\n${trimmed}`;
|
|
976
|
-
loadedSkills.push(skillName);
|
|
977
|
-
skillLoaded = true;
|
|
978
|
-
debugLog(`[session_load_context] User-local skill "${skillName}" loaded`);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
// โโโ Memory-Based Skill Discovery โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
982
|
-
// If recent handoff/ledger mentions a platform skill name, auto-load it.
|
|
983
|
-
// Only scans platform skill: keys โ user_skill: discovery is not automatic.
|
|
984
|
-
if (formattedContext.length > 0 && skillBlock.length < SKILL_BLOCK_CAP) {
|
|
985
|
-
const contextText = formattedContext.toLowerCase();
|
|
986
|
-
const allSkillKeys = await storage.getAllSettings?.() || {};
|
|
987
|
-
for (const [k, v] of Object.entries(allSkillKeys)) {
|
|
988
942
|
if (!k.startsWith("skill:") || !v)
|
|
989
943
|
continue;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const skillName = k.replace("skill:", "");
|
|
993
|
-
if (loadedSkills.includes(skillName))
|
|
944
|
+
const name = k.replace("skill:", "");
|
|
945
|
+
if (loadedSkills.includes(name))
|
|
994
946
|
continue;
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
skillBlock += `\n\n[๐ CONTEXT SKILL: ${skillName}]\n${trimmed}`;
|
|
1002
|
-
loadedSkills.push(skillName);
|
|
1003
|
-
debugLog(`[session_load_context] Context-triggered skill "${skillName}"`);
|
|
947
|
+
const content = v.trim();
|
|
948
|
+
if (content && content.trim()) {
|
|
949
|
+
skillBlock += '\n\n[๐ SKILL: ' + name + ']\n' + content.trim();
|
|
950
|
+
loadedSkills.push(name);
|
|
951
|
+
skillLoaded = true;
|
|
1004
952
|
}
|
|
1005
953
|
}
|
|
1006
954
|
}
|
|
1007
|
-
if (skippedSkills.length > 0) {
|
|
1008
|
-
skillBlock += `\n\n[โ ๏ธ ${skippedSkills.length} skills TRUNCATED by ${SKILL_BLOCK_CAP}-char cap โ NOT loaded: ${skippedSkills.join(", ")}. These rules are NOT in your context. Do not claim to follow them.]`;
|
|
1009
|
-
}
|
|
1010
955
|
// โโโ Agent Greeting Block โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1011
956
|
// Shows agent identity (name + role) and skill status after briefing.
|
|
1012
957
|
let greetingBlock = "";
|
|
@@ -1114,9 +1059,10 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1114
1059
|
responseText += `\n\n[โน๏ธ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
|
|
1115
1060
|
}
|
|
1116
1061
|
if (convId) {
|
|
1117
|
-
const { markContextLoaded } = await import("../session/sessionContext.js");
|
|
1062
|
+
const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
|
|
1118
1063
|
const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
|
|
1119
1064
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
1065
|
+
noteDriftSessionStart(convId);
|
|
1120
1066
|
}
|
|
1121
1067
|
const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
|
|
1122
1068
|
const boundariesHeader = `# OPERATING BOUNDARIES (v${BV}) โ enforced server-side, shown for transparency\n` +
|
|
@@ -1130,9 +1076,10 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1130
1076
|
}
|
|
1131
1077
|
let responseText = criticalPrefix + lowerPriority + historySection;
|
|
1132
1078
|
if (convId) {
|
|
1133
|
-
const { markContextLoaded } = await import("../session/sessionContext.js");
|
|
1079
|
+
const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
|
|
1134
1080
|
const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
|
|
1135
1081
|
markContextLoaded(convId, project, BOUNDARIES_VERSION);
|
|
1082
|
+
noteDriftSessionStart(convId);
|
|
1136
1083
|
}
|
|
1137
1084
|
const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
|
|
1138
1085
|
const boundariesHeader2 = `# OPERATING BOUNDARIES (v${BV2}) โ enforced server-side, shown for transparency\n` +
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 3. Call /api/generate locally โ return on success
|
|
12
12
|
* 4. On local fail, if cloud_fallback=true:
|
|
13
13
|
* - exchange synalux_sk_ โ JWT (cached)
|
|
14
|
-
* - POST synalux portal /api/v1/prism
|
|
14
|
+
* - POST synalux portal /api/v1/prism/inference
|
|
15
15
|
* - portal runs its own cascade (9B/27B/Claude by tier)
|
|
16
16
|
* 5. Return { output, backend, model_picked, ram_free_mb, latency_ms, used_cloud }
|
|
17
17
|
*
|
|
@@ -260,7 +260,7 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
|
|
|
260
260
|
const jwt = await getSynaluxJwt();
|
|
261
261
|
if (!jwt)
|
|
262
262
|
return { ok: false, reason: "jwt_exchange_failed" };
|
|
263
|
-
const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism
|
|
263
|
+
const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/inference`;
|
|
264
264
|
try {
|
|
265
265
|
let res = await fetch(url, {
|
|
266
266
|
method: "POST",
|
|
@@ -149,6 +149,10 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
|
|
|
149
149
|
type: "string",
|
|
150
150
|
description: "Optional. Session key for this conversation (same id used in session_save_ledger). When provided, marks the session as context-loaded server-side so project-scoped tools can verify working context without relying on hook-based enforcement. Required on non-Claude hosts.",
|
|
151
151
|
},
|
|
152
|
+
prompt: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Optional. User prompt text for keyword-triggered skill loading. When provided, the server matches against prompt_keywords in the routing table and loads additional skills. Fires on every call โ enables mid-session re-routing when the user's focus changes.",
|
|
155
|
+
},
|
|
152
156
|
},
|
|
153
157
|
required: ["project", "toolAction", "toolSummary"],
|
|
154
158
|
},
|
|
@@ -584,6 +588,8 @@ export function isSessionLoadContextArgs(args) {
|
|
|
584
588
|
return false;
|
|
585
589
|
if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
|
|
586
590
|
return false;
|
|
591
|
+
if (a.prompt !== undefined && typeof a.prompt !== "string")
|
|
592
|
+
return false;
|
|
587
593
|
return true;
|
|
588
594
|
}
|
|
589
595
|
// โโโ v2.0: Time Travel Tool Definitions โโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -1695,6 +1701,11 @@ export const SESSION_DETECT_DRIFT_TOOL = {
|
|
|
1695
1701
|
inputSchema: {
|
|
1696
1702
|
type: "object",
|
|
1697
1703
|
properties: {
|
|
1704
|
+
conversation_id: {
|
|
1705
|
+
type: "string",
|
|
1706
|
+
description: "Optional. Session key (same id used in session_load_context). " +
|
|
1707
|
+
"When provided, resets the server-side drift timer for this conversation.",
|
|
1708
|
+
},
|
|
1698
1709
|
project: {
|
|
1699
1710
|
type: "string",
|
|
1700
1711
|
description: "Project identifier. Must match the project used in session_save_ledger.",
|
|
@@ -1750,6 +1761,8 @@ export function isSessionDetectDriftArgs(args) {
|
|
|
1750
1761
|
if (typeof args !== "object" || args === null)
|
|
1751
1762
|
return false;
|
|
1752
1763
|
const a = args;
|
|
1764
|
+
if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
|
|
1765
|
+
return false;
|
|
1753
1766
|
if (typeof a.project !== "string" || !a.project.trim())
|
|
1754
1767
|
return false;
|
|
1755
1768
|
if (typeof a.goal !== "string" || !a.goal.trim())
|