prism-mcp-server 19.3.2 โ†’ 20.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/prism-mcp-server?color=cb0000&label=npm)](https://www.npmjs.com/package/prism-mcp-server)
6
6
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-00ADD8)](https://github.com/modelcontextprotocol/servers)
7
- [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
7
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
8
8
  [![Models on HuggingFace](https://img.shields.io/badge/๐Ÿค—-prism--coder-yellow)](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:
@@ -623,15 +642,30 @@ It reads `~/.prism-mcp/data.db` and POSTs entries to the portal. Ledger entries
623
642
 
624
643
  ---
625
644
 
626
- ## 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.
627
661
 
628
662
  | Product | License |
629
663
  |---|---|
630
- | **prism-mcp-server** (this repo) | [AGPL-3.0](LICENSE) |
664
+ | **prism-mcp-server** (this repo) | [Apache-2.0](LICENSE) |
631
665
  | **VS Code extension** (synalux-ai.synalux) | BSL-1.1 |
632
666
  | **Web IDE** (synalux.ai/coder) | Synalux Terms of Service |
633
- | **Prism AAC** | AGPL-3.0 |
667
+ | **Prism AAC** | Apache-2.0 |
634
668
 
635
- The AGPL-3.0 license covers the MCP server and its source code. The VS Code extension and Web IDE are separate products with their own licenses. Commercial hosted/managed deployment of the MCP server is available via the Synalux subscription.
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.
636
670
 
637
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 Voyage AI embeddings. Enables embedding_provider=voyage.
19
- * Voyage AI is the embedding provider recommended by Anthropic for use with
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 API Key โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
68
- // Used when embedding_provider = "voyage" in the dashboard.
69
- // Voyage AI is the embedding provider recommended by Anthropic for use
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
- * src/session/sessionContext.ts
2
+ * Session state tracking โ€” connection-scoped, in-process.
3
3
  *
4
- * Server-side session state, keyed on conversation_id (the same id threaded
5
- * through session_load_context / session_save_ledger / prism_infer).
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
- * WHY THIS EXISTS
8
- * ---------------
9
- * On Claude Code, "call session_load_context first" was enforced by the
10
- * guard_on_submit hook injecting a MANDATORY STARTUP reminder, and by
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
- * This module moves that state server-side so any host that calls
15
- * session_load_context gets its conversation marked as loaded โ€” regardless
16
- * of whether it ran the hook.
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
+ }
@@ -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, SYNALUX_CONFIGURED } from "../config.js";
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,36 @@ export async function sessionLoadContextHandler(args) {
638
638
  if (!data) {
639
639
  let freshSkillBlock = "";
640
640
  try {
641
- const { resolveSkillsForProject: resolveForFresh } = await import("./skillRouting.js");
642
- const freshResolved = await resolveForFresh(project);
643
- for (const entry of freshResolved.skills.filter(e => e.protected)) {
644
- const content = await getSetting(`skill:${entry.name}`, "");
645
- if (!content?.trim())
646
- continue;
647
- freshSkillBlock += `\n\n[๐Ÿ“œ SKILL: ${entry.name}]\n${content.trim()}`;
641
+ const { resolveSkills: resolveForFresh } = await import("./skillRouting.js");
642
+ const freshResolution = await resolveForFresh(project);
643
+ // Client-renders-content: load from local DB by resolved names
644
+ for (const name of freshResolution.names || []) {
645
+ const content = await getSetting(`skill:${name}`, "");
646
+ if (content?.trim()) {
647
+ freshSkillBlock += `\n\n[๐Ÿ“œ SKILL: ${name}]\n${content.trim()}`;
648
+ }
649
+ }
650
+ // Offline fallback: load protected skills from local DB
651
+ if (freshResolution.isOffline) {
652
+ 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'];
653
+ for (const name of protectedNames) {
654
+ if (freshResolution.names?.includes(name))
655
+ continue;
656
+ const content = await getSetting(`skill:${name}`, "");
657
+ if (content?.trim()) {
658
+ freshSkillBlock += `\n\n[๐Ÿ“œ SKILL: ${name}]\n${content.trim()}`;
659
+ }
660
+ }
648
661
  }
649
662
  }
650
663
  catch {
651
664
  debugLog(`[session_load_context] Fresh project skill injection failed โ€” continuing without`);
652
665
  }
653
666
  if (convId) {
654
- const { markContextLoaded } = await import("../session/sessionContext.js");
667
+ const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
655
668
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
656
669
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
670
+ noteDriftSessionStart(convId);
657
671
  }
658
672
  const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
659
673
  const boundariesHeader0 = `# OPERATING BOUNDARIES (v${BV0}) โ€” enforced server-side, shown for transparency\n` +
@@ -907,106 +921,46 @@ export async function sessionLoadContextHandler(args) {
907
921
  debugLog(`[session_load_context] Injecting skill for role="${effectiveRole}" (${skillContent.length} chars)`);
908
922
  }
909
923
  }
910
- // โ”€โ”€โ”€ Project-Aware Skill Injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
911
- // Skills are priority-sorted and cap-aware. Protected skills always load
912
- // (they bypass the cap check). This prevents the silent-truncation bug
913
- // where important behavioral skills were dropped because large low-priority
914
- // skills consumed the budget first.
915
- const { resolveSkillsForProject } = await import("./skillRouting.js");
916
- const resolved = await resolveSkillsForProject(project);
917
- const sortedSkills = resolved.skills;
918
- const userLocalPolicy = resolved.user_local;
919
- // F5: surface offline routing to the agent so it knows project/keyword skills may be missing
920
- if (resolved.isOffline) {
921
- skillBlock += `\n\n[โš ๏ธ OFFLINE ROUTING: synalux.ai unreachable โ€” using stale or fallback routing table. Project-specific and keyword-triggered skills may be missing. Universal protected skills still load.]`;
922
- }
923
- let synaluxContent = {};
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())
924
+ // โ”€โ”€โ”€ All other skills resolved by portal API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
925
+ const { resolveSkills, _setStorage } = await import("./skillRouting.js");
926
+ _setStorage(async (k, v) => { try {
927
+ await storage.setSetting?.(k, v);
928
+ }
929
+ catch { } }, async (k) => { try {
930
+ return await getSetting(k, "");
931
+ }
932
+ catch {
933
+ return "";
934
+ } });
935
+ const skillResolution = await resolveSkills(project, prompt, effectiveRole);
936
+ // Client-renders-content: portal returns names, we load content from local DB
937
+ for (const name of skillResolution.names || []) {
938
+ if (loadedSkills.includes(name))
937
939
  continue;
938
- const trimmed = content.trim();
939
- if (entry.protected) {
940
- skillBlock += `\n\n[๐Ÿ“œ SKILL: ${entry.name}]\n${trimmed}`;
941
- loadedSkills.push(entry.name);
940
+ const content = await getSetting(`skill:${name}`, "");
941
+ if (content?.trim()) {
942
+ skillBlock += `\n\n[๐Ÿ“œ SKILL: ${name}]\n${content.trim()}`;
943
+ loadedSkills.push(name);
942
944
  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
945
  }
951
- const source = synaluxContent[entry.name] ? "synalux" : "local-platform";
952
- skillBlock += `\n\n[๐Ÿ“œ SKILL: ${entry.name}]\n${trimmed}`;
953
- loadedSkills.push(entry.name);
954
- skillLoaded = true;
955
- debugLog(`[session_load_context] Skill "${entry.name}" loaded (${source}, p${entry.priority}) [${skillBlock.length}/${SKILL_BLOCK_CAP} chars]`);
956
- }
957
- // โ”€โ”€โ”€ User-Local Skills โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
958
- // Loaded ONLY when user_local.enabled=true (set in Synalux routing table
959
- // or explicitly requested). Stored under user_skill: prefix โ€” users can
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:";
946
+ }
947
+ // Offline fallback: load whatever's in local DB
948
+ if (skillResolution.isOffline) {
963
949
  const allSettings = await storage.getAllSettings?.() || {};
964
950
  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
951
  if (!k.startsWith("skill:") || !v)
989
952
  continue;
990
- if (skillBlock.length >= SKILL_BLOCK_CAP)
991
- break;
992
- const skillName = k.replace("skill:", "");
993
- if (loadedSkills.includes(skillName))
953
+ const name = k.replace("skill:", "");
954
+ if (loadedSkills.includes(name))
994
955
  continue;
995
- if (contextText.includes(skillName.replace(/-/g, " ")) || contextText.includes(skillName)) {
996
- const trimmed = v.trim();
997
- if (skillBlock.length + trimmed.length > SKILL_BLOCK_CAP && loadedSkills.length > 0) {
998
- skippedSkills.push(skillName);
999
- continue;
1000
- }
1001
- skillBlock += `\n\n[๐Ÿ“œ CONTEXT SKILL: ${skillName}]\n${trimmed}`;
1002
- loadedSkills.push(skillName);
1003
- debugLog(`[session_load_context] Context-triggered skill "${skillName}"`);
956
+ const content = v.trim();
957
+ if (content && content.trim()) {
958
+ skillBlock += '\n\n[๐Ÿ“œ SKILL: ' + name + ']\n' + content.trim();
959
+ loadedSkills.push(name);
960
+ skillLoaded = true;
1004
961
  }
1005
962
  }
1006
963
  }
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
964
  // โ”€โ”€โ”€ Agent Greeting Block โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1011
965
  // Shows agent identity (name + role) and skill status after briefing.
1012
966
  let greetingBlock = "";
@@ -1114,9 +1068,10 @@ export async function sessionLoadContextHandler(args) {
1114
1068
  responseText += `\n\n[โ„น๏ธ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
1115
1069
  }
1116
1070
  if (convId) {
1117
- const { markContextLoaded } = await import("../session/sessionContext.js");
1071
+ const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
1118
1072
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1119
1073
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
1074
+ noteDriftSessionStart(convId);
1120
1075
  }
1121
1076
  const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
1122
1077
  const boundariesHeader = `# OPERATING BOUNDARIES (v${BV}) โ€” enforced server-side, shown for transparency\n` +
@@ -1130,9 +1085,10 @@ export async function sessionLoadContextHandler(args) {
1130
1085
  }
1131
1086
  let responseText = criticalPrefix + lowerPriority + historySection;
1132
1087
  if (convId) {
1133
- const { markContextLoaded } = await import("../session/sessionContext.js");
1088
+ const { markContextLoaded, noteDriftSessionStart } = await import("../session/sessionContext.js");
1134
1089
  const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1135
1090
  markContextLoaded(convId, project, BOUNDARIES_VERSION);
1091
+ noteDriftSessionStart(convId);
1136
1092
  }
1137
1093
  const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
1138
1094
  const boundariesHeader2 = `# OPERATING BOUNDARIES (v${BV2}) โ€” enforced server-side, shown for transparency\n` +
@@ -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())