prism-mcp-server 19.2.9 → 19.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * boundaries.ts unit tests
3
+ *
4
+ * Verifies structural invariants of the operating boundaries export:
5
+ * - BOUNDARIES_VERSION is present and non-empty
6
+ * - BOUNDARIES_TEXT covers the five required sections
7
+ * - Safety-critical concepts are mentioned (so a future edit doesn't silently
8
+ * remove them without also bumping the version and updating tests)
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { BOUNDARIES_VERSION, BOUNDARIES_TEXT } from "../../boundaries/boundaries.js";
12
+ describe("BOUNDARIES_VERSION", () => {
13
+ it("is a non-empty string", () => {
14
+ expect(typeof BOUNDARIES_VERSION).toBe("string");
15
+ expect(BOUNDARIES_VERSION.length).toBeGreaterThan(0);
16
+ });
17
+ });
18
+ describe("BOUNDARIES_TEXT structure", () => {
19
+ it("is a non-empty string", () => {
20
+ expect(typeof BOUNDARIES_TEXT).toBe("string");
21
+ expect(BOUNDARIES_TEXT.length).toBeGreaterThan(100);
22
+ });
23
+ it("has no leading or trailing whitespace (trim() was applied)", () => {
24
+ expect(BOUNDARIES_TEXT).toBe(BOUNDARIES_TEXT.trim());
25
+ });
26
+ it("contains a safety gates section", () => {
27
+ expect(BOUNDARIES_TEXT).toMatch(/safety gates/i);
28
+ });
29
+ it("contains a BCBA clinical standards section", () => {
30
+ expect(BOUNDARIES_TEXT).toMatch(/bcba/i);
31
+ });
32
+ it("contains a correctness gates section", () => {
33
+ expect(BOUNDARIES_TEXT).toMatch(/correctness gates/i);
34
+ });
35
+ it("contains an inference routing section", () => {
36
+ expect(BOUNDARIES_TEXT).toMatch(/inference routing/i);
37
+ });
38
+ it("contains a host note section", () => {
39
+ expect(BOUNDARIES_TEXT).toMatch(/host note/i);
40
+ });
41
+ it("asserts that enforcement is server-side (not instruction-based)", () => {
42
+ expect(BOUNDARIES_TEXT).toMatch(/server.*enforc|enforc.*server/i);
43
+ });
44
+ it("references the fail-closed rule for reserved + no-cloud", () => {
45
+ expect(BOUNDARIES_TEXT).toMatch(/fail.?closed|refused/i);
46
+ });
47
+ it("states AAC access is never restricted as a consequence", () => {
48
+ expect(BOUNDARIES_TEXT).toMatch(/AAC access is never restricted/i);
49
+ });
50
+ it("references restraint as a RESERVED category", () => {
51
+ expect(BOUNDARIES_TEXT).toMatch(/restraint/i);
52
+ expect(BOUNDARIES_TEXT).toMatch(/RESERVED/i);
53
+ });
54
+ it("mentions session_save_ledger and session_save_handoff in correctness section", () => {
55
+ expect(BOUNDARIES_TEXT).toContain("session_save_ledger");
56
+ expect(BOUNDARIES_TEXT).toContain("session_save_handoff");
57
+ });
58
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * src/boundaries/boundaries.ts
3
+ *
4
+ * Operating boundaries delivered in every session_load_context result.
5
+ *
6
+ * These boundaries are enforced server-side in code (prism_infer safety
7
+ * gates, requireContextLoaded). This text is belt-and-suspenders for a
8
+ * cooperative host — it cannot be removed to bypass enforcement.
9
+ *
10
+ * Update BOUNDARIES_VERSION any time the text changes so session drift
11
+ * detection can flag stale sessions.
12
+ */
13
+ export const BOUNDARIES_VERSION = "1";
14
+ export const BOUNDARIES_TEXT = `
15
+ ## OPERATING BOUNDARIES — server-enforced, shown for transparency
16
+
17
+ ### 1. Safety gates (unconditional — run before and after every model call)
18
+ - Crisis/self-harm inputs are intercepted before reaching any model.
19
+ - BCBA reserved categories (restraint, seclusion, physical management, dosing) route
20
+ to cloud or refuse; they NEVER generate locally. Fail-closed: if cloud is unavailable
21
+ and the prompt is reserved, the request is refused — never downgraded to local.
22
+ - Dangerous output (restraint instructions, overdose methods, self-harm guidance)
23
+ is blocked regardless of which host requested it.
24
+
25
+ ### 2. BCBA clinical standards
26
+ - Apply ABA principles grounded in the current BACB Ethics Code and Task List (5th Ed).
27
+ - Use evidence-based interventions: FCT, DRA, DRO, NCR, antecedent modifications.
28
+ - Least restrictive, dignity-preserving, trauma-informed procedures always.
29
+ - AAC access is never restricted as a consequence.
30
+ - Physical management / restraint / seclusion are RESERVED — cloud only, with audit.
31
+
32
+ ### 3. Correctness gates (project-scoped write tools)
33
+ - session_save_ledger and session_save_handoff require a loaded project context
34
+ (conversation_id that called session_load_context successfully).
35
+ - This prevents a non-Claude host from writing state it hasn't confirmed.
36
+
37
+ ### 4. Inference routing
38
+ - Local inference (Ollama) runs ONLY for OBVIOUS_NOT_RESERVED prompts (Layer 1 verdict).
39
+ - RESERVED / UNCERTAIN / classifier errors escalate to cloud. Never downgrade reserved
40
+ prompts to local — that is exactly what Layer 1 flagged.
41
+ - Cloud must never be reached directly by a host; all cloud inference routes through
42
+ the Synalux portal for billing, tier-gating, and HIPAA audit.
43
+
44
+ ### 5. Host note
45
+ These boundaries are enforced by the server. They apply identically whether the host
46
+ is Claude Code, Gemini, an autonomous script, or a cron job. A host that does not read
47
+ this text still cannot bypass the enforcement — it is in code, not instructions.
48
+ `.trim();
@@ -0,0 +1,133 @@
1
+ /**
2
+ * sessionContext.ts unit tests
3
+ *
4
+ * Covers: markContextLoaded, requireContextLoaded, noteInferenceForSession,
5
+ * getSessionState, TTL eviction, and fail-closed behaviour for unknown sessions.
6
+ *
7
+ * The module uses in-process Map state. Each test imports a fresh module instance
8
+ * via vi.resetModules() + dynamic re-import so tests are isolated without needing
9
+ * an exported reset function.
10
+ */
11
+ import { describe, it, expect, beforeEach, vi } from "vitest";
12
+ // Reset module registry before each test so the in-memory Map starts empty.
13
+ let markContextLoaded;
14
+ let requireContextLoaded;
15
+ let noteInferenceForSession;
16
+ let getSessionState;
17
+ beforeEach(async () => {
18
+ vi.resetModules();
19
+ const mod = await import("../../session/sessionContext.js");
20
+ markContextLoaded = mod.markContextLoaded;
21
+ requireContextLoaded = mod.requireContextLoaded;
22
+ noteInferenceForSession = mod.noteInferenceForSession;
23
+ getSessionState = mod.getSessionState;
24
+ });
25
+ describe("requireContextLoaded — fail-closed defaults", () => {
26
+ it("blocks an unknown conversation (never seen)", () => {
27
+ const result = requireContextLoaded("never-seen-id");
28
+ expect(result).not.toBeNull();
29
+ expect(result.blocked).toBe(true);
30
+ if (result && result.blocked)
31
+ expect(result.error).toContain("context_not_loaded");
32
+ });
33
+ it("allows (returns null) when conversation_id is undefined — gate is opt-in", () => {
34
+ // Callers without a conversation_id (auto-push hosts, resource readers,
35
+ // legacy clients) are not gated — they use the session-agnostic interface.
36
+ const result = requireContextLoaded(undefined);
37
+ expect(result).toBeNull();
38
+ });
39
+ it("blocks (hard) when conversation_id is empty string — empty string is not opt-in bypass", () => {
40
+ // "" is not the same as undefined. An empty string means the caller explicitly
41
+ // provided a conversation_id but it's invalid. The gate should block, not bypass.
42
+ const result = requireContextLoaded("");
43
+ expect(result).not.toBeNull();
44
+ expect(result.blocked).toBe(true);
45
+ if (result && result.blocked)
46
+ expect(result.error).toContain("context_not_loaded");
47
+ });
48
+ it("blocks a session by unknown id even if noteInference was called for it", () => {
49
+ // noteInferenceForSession no longer creates stubs, so an unregistered id
50
+ // is still unknown to the gate.
51
+ noteInferenceForSession("conv-telemetry-only", { backend: "local", usedCloud: false });
52
+ const result = requireContextLoaded("conv-telemetry-only");
53
+ expect(result).not.toBeNull();
54
+ expect(result.blocked).toBe(true);
55
+ });
56
+ });
57
+ describe("markContextLoaded → requireContextLoaded lifecycle", () => {
58
+ it("returns null (pass) after markContextLoaded is called", () => {
59
+ markContextLoaded("conv-abc", "project-x", "1");
60
+ expect(requireContextLoaded("conv-abc")).toBeNull();
61
+ });
62
+ it("records project and boundariesVersion on the session", () => {
63
+ markContextLoaded("conv-meta", "my-project", "42");
64
+ const state = getSessionState("conv-meta");
65
+ expect(state).not.toBeNull();
66
+ expect(state.project).toBe("my-project");
67
+ expect(state.boundariesVersion).toBe("42");
68
+ expect(state.contextLoaded).toBe(true);
69
+ });
70
+ it("is idempotent — calling twice does not break state", () => {
71
+ // Use the actual BOUNDARIES_VERSION ("1") so no drift warning fires.
72
+ markContextLoaded("conv-idem", "proj", "1");
73
+ markContextLoaded("conv-idem", "proj-updated", "1");
74
+ const state = getSessionState("conv-idem");
75
+ expect(state.project).toBe("proj-updated");
76
+ expect(state.boundariesVersion).toBe("1");
77
+ expect(requireContextLoaded("conv-idem")).toBeNull();
78
+ });
79
+ it("isolates sessions — loading one does not unblock another", () => {
80
+ markContextLoaded("conv-A", "proj", "1");
81
+ expect(requireContextLoaded("conv-A")).toBeNull();
82
+ expect(requireContextLoaded("conv-B")).not.toBeNull();
83
+ });
84
+ });
85
+ describe("noteInferenceForSession", () => {
86
+ it("increments inferenceCalls on every call", () => {
87
+ markContextLoaded("conv-inf", "proj", "1");
88
+ noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
89
+ noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
90
+ const state = getSessionState("conv-inf");
91
+ expect(state.inferenceCalls).toBe(2);
92
+ });
93
+ it("increments usedCloudCalls only for cloud calls", () => {
94
+ markContextLoaded("conv-cloud", "proj", "1");
95
+ noteInferenceForSession("conv-cloud", { backend: "cloud", usedCloud: true });
96
+ noteInferenceForSession("conv-cloud", { backend: "local", usedCloud: false });
97
+ const state = getSessionState("conv-cloud");
98
+ expect(state.inferenceCalls).toBe(2);
99
+ expect(state.usedCloudCalls).toBe(1);
100
+ });
101
+ it("does NOT create a ghost stub for an unregistered session — only updates existing sessions", () => {
102
+ // noteInferenceForSession used to call getOrInit, creating stub entries
103
+ // with contextLoaded=false for every conversation_id that infers.
104
+ // Ghost stubs accumulate in the LRU and crowd out real sessions.
105
+ // The fix: no-op when the session doesn't exist yet.
106
+ noteInferenceForSession("conv-new-via-note", { backend: "local", usedCloud: false });
107
+ expect(getSessionState("conv-new-via-note")).toBeNull();
108
+ });
109
+ });
110
+ describe("getSessionState", () => {
111
+ it("returns null for an unknown session", () => {
112
+ expect(getSessionState("does-not-exist")).toBeNull();
113
+ });
114
+ it("returns the current state object for a known session", () => {
115
+ markContextLoaded("conv-get", "proj", "1");
116
+ const state = getSessionState("conv-get");
117
+ expect(state).not.toBeNull();
118
+ expect(state.contextLoaded).toBe(true);
119
+ });
120
+ });
121
+ describe("lastSeen update", () => {
122
+ it("updates lastSeen on every requireContextLoaded call", async () => {
123
+ markContextLoaded("conv-ts", "proj", "1");
124
+ const before = getSessionState("conv-ts").lastSeen;
125
+ // Advance time by mocking Date.now via vi.useFakeTimers
126
+ vi.useFakeTimers();
127
+ vi.advanceTimersByTime(5000);
128
+ requireContextLoaded("conv-ts");
129
+ const after = getSessionState("conv-ts").lastSeen;
130
+ vi.useRealTimers();
131
+ expect(after).toBeGreaterThanOrEqual(before);
132
+ });
133
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * src/session/sessionContext.ts
3
+ *
4
+ * Server-side session state, keyed on conversation_id (the same id threaded
5
+ * through session_load_context / session_save_ledger / prism_infer).
6
+ *
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).
13
+ *
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.
31
+ */
32
+ import { BOUNDARIES_VERSION as CURRENT_BOUNDARIES_VERSION } from "../boundaries/boundaries.js";
33
+ const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 h — conversation-scoped
34
+ const MAX_SESSIONS = 10_000;
35
+ // JS Map preserves insertion order. Touch-on-access (delete + re-insert) keeps
36
+ // the Map ordered LRU-last so eviction can pop the first key in O(1).
37
+ const sessions = new Map();
38
+ function touch(conversationId, s) {
39
+ // Re-insert to move this entry to the end of insertion order (most-recently-used).
40
+ sessions.delete(conversationId);
41
+ s.lastSeen = Date.now();
42
+ sessions.set(conversationId, s);
43
+ }
44
+ function evictStale() {
45
+ const cutoff = Date.now() - SESSION_TTL_MS;
46
+ for (const [k, v] of sessions) {
47
+ if (v.lastSeen < cutoff)
48
+ sessions.delete(k);
49
+ }
50
+ // Hard cap: evict least-recently-used entries (the ones at the front of the Map,
51
+ // because touch() always re-inserts accessed entries at the back).
52
+ // Guard: use !== undefined (not truthy) so an empty-string key doesn't stall the loop.
53
+ while (sessions.size > MAX_SESSIONS) {
54
+ const oldest = sessions.keys().next().value;
55
+ if (oldest !== undefined)
56
+ sessions.delete(oldest);
57
+ else
58
+ break;
59
+ }
60
+ }
61
+ function getOrInit(conversationId) {
62
+ let s = sessions.get(conversationId);
63
+ if (!s) {
64
+ s = {
65
+ contextLoaded: false,
66
+ boundariesVersion: null,
67
+ project: null,
68
+ lastSeen: Date.now(),
69
+ inferenceCalls: 0,
70
+ usedCloudCalls: 0,
71
+ };
72
+ sessions.set(conversationId, s);
73
+ if (sessions.size > MAX_SESSIONS)
74
+ evictStale();
75
+ return s;
76
+ }
77
+ // Touch on access — maintains O(1) LRU eviction order in the Map.
78
+ touch(conversationId, s);
79
+ return s;
80
+ }
81
+ /**
82
+ * Called by sessionLoadContextHandler after it successfully assembles context.
83
+ * Server-side equivalent of mark_loaded.py — fires from the tool handler
84
+ * itself, so it works for every host, not just Claude Code.
85
+ */
86
+ export function markContextLoaded(conversationId, project, boundariesVersion) {
87
+ const s = getOrInit(conversationId);
88
+ s.contextLoaded = true;
89
+ s.project = project;
90
+ s.boundariesVersion = boundariesVersion;
91
+ }
92
+ /**
93
+ * Soft gate for handlers that need project context to be CORRECT (not safe).
94
+ *
95
+ * Returns:
96
+ * null — OK, proceed
97
+ * { blocked: true, error } — hard block; return the error verbatim to the model
98
+ * { blocked: false, warning } — proceed, but prepend the warning to the response
99
+ * (emitted when the session was loaded with an older
100
+ * BOUNDARIES_VERSION — the host should reload context)
101
+ *
102
+ * Fail-closed: unknown or expired session → hard block.
103
+ * Does NOT gate safety. Do not call this from prism_infer.
104
+ */
105
+ export function requireContextLoaded(conversationId) {
106
+ // No conversation_id (undefined) means the caller is using the session-agnostic
107
+ // interface (auto-push host, resource reader, or legacy client). Allow through —
108
+ // the gate is opt-in when a conversation_id is explicitly provided.
109
+ // NOTE: use === undefined, not !conversationId, so that an empty-string ""
110
+ // conversation_id still falls through to the sessions.get() lookup and gets
111
+ // blocked (hard block) instead of silently bypassing the gate.
112
+ if (conversationId === undefined)
113
+ return null;
114
+ const s = sessions.get(conversationId);
115
+ // #9: Enforce TTL on reads. A session loaded 6 h+ ago is treated as expired
116
+ // even if no write has triggered eviction yet. Evict immediately on detection.
117
+ if (s && (Date.now() - s.lastSeen) > SESSION_TTL_MS) {
118
+ sessions.delete(conversationId);
119
+ return {
120
+ blocked: true,
121
+ error: "context_not_loaded: session expired (6 h TTL). Call " +
122
+ "session_load_context(project, conversation_id) again to reload context. " +
123
+ "(Enforced server-side — applies to every host.)",
124
+ };
125
+ }
126
+ if (!s || !s.contextLoaded) {
127
+ return {
128
+ blocked: true,
129
+ error: "context_not_loaded: call session_load_context(project, conversation_id) " +
130
+ "before this action. This project-scoped tool needs confirmed working context " +
131
+ "to act correctly. (Enforced server-side — applies to every host.)",
132
+ };
133
+ }
134
+ // Touch on valid read — maintains LRU order.
135
+ touch(conversationId, s);
136
+ // #15: Soft warning when this session was loaded with an older BOUNDARIES_VERSION.
137
+ // The server may have been updated mid-session. Don't block writes — that would
138
+ // be disruptive — but advise the host to reload context to pick up the new boundaries.
139
+ if (s.boundariesVersion !== null && s.boundariesVersion !== CURRENT_BOUNDARIES_VERSION) {
140
+ return {
141
+ blocked: false,
142
+ warning: `[advisory] Operating boundaries updated (session loaded v${s.boundariesVersion}, ` +
143
+ `server now at v${CURRENT_BOUNDARIES_VERSION}). Call session_load_context again ` +
144
+ `to receive the latest boundaries. Proceeding with current write.`,
145
+ };
146
+ }
147
+ return null;
148
+ }
149
+ /** Best-effort telemetry from prism_infer. Never affects a safety decision. */
150
+ export function noteInferenceForSession(conversationId, info) {
151
+ // Only update sessions that already exist — don't create ghost stubs for
152
+ // conversations that never called session_load_context. Ghost stubs would
153
+ // accumulate in the LRU and crowd out legitimate registered sessions.
154
+ const s = sessions.get(conversationId);
155
+ if (!s)
156
+ return;
157
+ s.inferenceCalls += 1;
158
+ if (info.usedCloud)
159
+ s.usedCloudCalls += 1;
160
+ touch(conversationId, s);
161
+ }
162
+ /** For metrics / session health checks. */
163
+ export function getSessionState(conversationId) {
164
+ return sessions.get(conversationId) ?? null;
165
+ }
@@ -121,6 +121,19 @@ vi.mock("../../../src/utils/cognitiveMemory.js", () => ({
121
121
  computeEffectiveImportance: vi.fn((imp) => imp),
122
122
  recordMemoryAccess: vi.fn(),
123
123
  }));
124
+ // Session context gate — default to "pass" (null) so existing tests are unaffected.
125
+ // Gate-blocking behaviour is tested explicitly in the "context gate" sections below.
126
+ vi.mock("../../../src/session/sessionContext.js", () => ({
127
+ markContextLoaded: vi.fn(),
128
+ requireContextLoaded: vi.fn(() => null),
129
+ noteInferenceForSession: vi.fn(),
130
+ getSessionState: vi.fn(() => null),
131
+ }));
132
+ // Boundaries — return minimal stubs so load-context tests don't depend on exact text.
133
+ vi.mock("../../../src/boundaries/boundaries.js", () => ({
134
+ BOUNDARIES_VERSION: "test",
135
+ BOUNDARIES_TEXT: "# BOUNDARIES STUB",
136
+ }));
124
137
  vi.mock("../../../src/tools/commonHelpers.js", () => ({
125
138
  redactSettings: vi.fn((s) => s),
126
139
  toMarkdown: vi.fn(() => "# Markdown Export"),
@@ -133,6 +146,7 @@ vi.mock("../../../src/utils/vaultExporter.js", () => ({
133
146
  // ======================================================================
134
147
  import { getStorage } from "../../../src/storage/index.js";
135
148
  import { getSetting, getAllSettings } from "../../../src/storage/configStorage.js";
149
+ import { requireContextLoaded, markContextLoaded } from "../../../src/session/sessionContext.js";
136
150
  import { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler, memoryHistoryHandler, sessionSaveImageHandler, sessionViewImageHandler, sanitizeMemoryInput, } from "../../../src/tools/ledgerHandlers.js";
137
151
  const mockGetStorage = vi.mocked(getStorage);
138
152
  const mockGetSetting = vi.mocked(getSetting);
@@ -329,6 +343,36 @@ describe("ledgerHandlers", () => {
329
343
  storage.saveLedger.mockRejectedValue(new Error("DB write failed"));
330
344
  await expect(sessionSaveLedgerHandler(validArgs)).rejects.toThrow("DB write failed");
331
345
  });
346
+ // --- Context gate ---
347
+ it("blocks save and returns structured error when context not loaded", async () => {
348
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
349
+ blocked: true,
350
+ error: "context_not_loaded: call session_load_context first.",
351
+ });
352
+ const result = await sessionSaveLedgerHandler(validArgs);
353
+ expect(result.isError).toBe(true);
354
+ expect(result.content[0].text).toContain("context_not_loaded");
355
+ expect(storage.saveLedger).not.toHaveBeenCalled();
356
+ });
357
+ it("passes through to storage when context is loaded (gate returns null)", async () => {
358
+ vi.mocked(requireContextLoaded).mockReturnValueOnce(null);
359
+ const result = await sessionSaveLedgerHandler(validArgs);
360
+ expect(result.isError).toBe(false);
361
+ expect(storage.saveLedger).toHaveBeenCalledTimes(1);
362
+ });
363
+ it("proceeds and prepends warning when gate returns { blocked: false, warning } (version drift)", async () => {
364
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
365
+ blocked: false,
366
+ warning: "[advisory] Operating boundaries updated. Call session_load_context again.",
367
+ });
368
+ const result = await sessionSaveLedgerHandler(validArgs);
369
+ // Must NOT block — write proceeds
370
+ expect(result.isError).toBe(false);
371
+ expect(storage.saveLedger).toHaveBeenCalledTimes(1);
372
+ // Warning is prepended to the success text
373
+ expect(result.content[0].text).toContain("[advisory] Operating boundaries updated");
374
+ expect(result.content[0].text).toContain("✅ Session ledger saved");
375
+ });
332
376
  });
333
377
  // ====================================================================
334
378
  // 3. sessionLoadContextHandler
@@ -473,6 +517,24 @@ describe("ledgerHandlers", () => {
473
517
  storage.loadContext.mockRejectedValue(new Error("Connection timeout"));
474
518
  await expect(sessionLoadContextHandler(validArgs)).rejects.toThrow("Connection timeout");
475
519
  });
520
+ // --- conversation_id / markContextLoaded ---
521
+ it("calls markContextLoaded when conversation_id is provided", async () => {
522
+ storage.loadContext.mockResolvedValue(null);
523
+ await sessionLoadContextHandler({ project: "test-project", conversation_id: "conv-xyz" });
524
+ expect(vi.mocked(markContextLoaded)).toHaveBeenCalledWith("conv-xyz", "test-project", "test");
525
+ });
526
+ it("does not call markContextLoaded when conversation_id is absent", async () => {
527
+ storage.loadContext.mockResolvedValue(null);
528
+ await sessionLoadContextHandler({ project: "test-project" });
529
+ expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
530
+ });
531
+ it("prepends BOUNDARIES header to every response", async () => {
532
+ storage.loadContext.mockResolvedValue(null);
533
+ const result = await sessionLoadContextHandler(validArgs);
534
+ const text = result.content[0].text;
535
+ expect(text).toContain("OPERATING BOUNDARIES");
536
+ expect(text).toContain("BOUNDARIES STUB");
537
+ });
476
538
  });
477
539
  // ====================================================================
478
540
  // 4. sessionSaveHandoffHandler
@@ -606,6 +668,38 @@ describe("ledgerHandlers", () => {
606
668
  storage.saveHandoff.mockRejectedValue(new Error("Write conflict"));
607
669
  await expect(sessionSaveHandoffHandler(validArgs)).rejects.toThrow("Write conflict");
608
670
  });
671
+ // --- Context gate ---
672
+ it("blocks handoff save and returns structured error when context not loaded", async () => {
673
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
674
+ blocked: true,
675
+ error: "context_not_loaded: call session_load_context first.",
676
+ });
677
+ const result = await sessionSaveHandoffHandler(validArgs);
678
+ expect(result.isError).toBe(true);
679
+ expect(result.content[0].text).toContain("context_not_loaded");
680
+ expect(storage.saveHandoff).not.toHaveBeenCalled();
681
+ });
682
+ it("passes through to storage when context is loaded (gate returns null)", async () => {
683
+ storage.saveHandoff.mockResolvedValue({ status: "created", version: 1 });
684
+ vi.mocked(requireContextLoaded).mockReturnValueOnce(null);
685
+ const result = await sessionSaveHandoffHandler(validArgs);
686
+ expect(result.isError).toBe(false);
687
+ expect(storage.saveHandoff).toHaveBeenCalledTimes(1);
688
+ });
689
+ it("proceeds and prepends warning when gate returns { blocked: false, warning } (version drift)", async () => {
690
+ storage.saveHandoff.mockResolvedValue({ status: "created", version: 1 });
691
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
692
+ blocked: false,
693
+ warning: "[advisory] Operating boundaries updated. Call session_load_context again.",
694
+ });
695
+ const result = await sessionSaveHandoffHandler(validArgs);
696
+ // Must NOT block — write proceeds
697
+ expect(result.isError).toBe(false);
698
+ expect(storage.saveHandoff).toHaveBeenCalledTimes(1);
699
+ // Warning is prepended to the success text
700
+ expect(result.content[0].text).toContain("[advisory] Operating boundaries updated");
701
+ expect(result.content[0].text).toContain("✅ Handoff");
702
+ });
609
703
  });
610
704
  // ====================================================================
611
705
  // 5. memoryHistoryHandler
@@ -94,6 +94,19 @@ export async function sessionSaveLedgerHandler(args) {
94
94
  if (!isSessionSaveLedgerArgs(args)) {
95
95
  throw new Error("Invalid arguments for session_save_ledger");
96
96
  }
97
+ // Host-agnostic context gate: any host that calls save_ledger without first
98
+ // loading context (on any host type) gets a structured error instead of a
99
+ // silently wrong write. Does NOT gate safety — prism_infer handles that.
100
+ let _saveLedgerGateWarning;
101
+ {
102
+ const { requireContextLoaded } = await import("../session/sessionContext.js");
103
+ const gate = requireContextLoaded(args.conversation_id);
104
+ if (gate !== null && gate.blocked) {
105
+ return { content: [{ type: "text", text: gate.error }], isError: true };
106
+ }
107
+ if (gate !== null && !gate.blocked)
108
+ _saveLedgerGateWarning = gate.warning;
109
+ }
97
110
  // SECURITY: Sanitize all text fields to prevent stored prompt injection
98
111
  let project = args.project;
99
112
  const conversation_id = args.conversation_id;
@@ -234,7 +247,8 @@ export async function sessionSaveLedgerHandler(args) {
234
247
  return {
235
248
  content: [{
236
249
  type: "text",
237
- text: `✅ Session ledger saved for project "${project}"\n` +
250
+ text: (_saveLedgerGateWarning ? `⚠️ ${_saveLedgerGateWarning}\n\n` : "") +
251
+ `✅ Session ledger saved for project "${project}"\n` +
238
252
  `Summary: ${summary}\n` +
239
253
  (todos?.length ? `TODOs: ${todos.length} items\n` : "") +
240
254
  (files_changed?.length ? `Files changed: ${files_changed.length}\n` : "") +
@@ -250,6 +264,16 @@ export async function sessionSaveHandoffHandler(args, server) {
250
264
  if (!isSessionSaveHandoffArgs(args)) {
251
265
  throw new Error("Invalid arguments for session_save_handoff");
252
266
  }
267
+ let _saveHandoffGateWarning;
268
+ {
269
+ const { requireContextLoaded } = await import("../session/sessionContext.js");
270
+ const gate = requireContextLoaded(args.conversation_id);
271
+ if (gate !== null && gate.blocked) {
272
+ return { content: [{ type: "text", text: gate.error }], isError: true };
273
+ }
274
+ if (gate !== null && !gate.blocked)
275
+ _saveHandoffGateWarning = gate.warning;
276
+ }
253
277
  // SECURITY: Sanitize all text fields to prevent stored prompt injection
254
278
  const project = args.project;
255
279
  const expected_version = args.expected_version;
@@ -553,22 +577,23 @@ export async function sessionSaveHandoffHandler(args, server) {
553
577
  }
554
578
  const metricsBlock = formatInferenceMetrics();
555
579
  // Build response text based on whether a CRDT merge occurred
556
- const responseText = isMerged
557
- ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
558
- `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
559
- (last_summary ? `Summary: ${last_summary}\n` : "") +
560
- metricsBlock +
561
- `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
562
- `to maintain concurrency control.`
563
- : `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
564
- `(version: ${newVersion})\n` +
565
- (last_summary ? `Last summary: ${last_summary}\n` : "") +
566
- (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
567
- (active_branch ? `Active branch: ${active_branch}\n` : "") +
568
- `📊 Embedding generation queued for semantic search.\n` +
569
- metricsBlock +
570
- `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
571
- `to maintain concurrency control.`;
580
+ const responseText = (_saveHandoffGateWarning ? `⚠️ ${_saveHandoffGateWarning}\n\n` : "") +
581
+ (isMerged
582
+ ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
583
+ `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
584
+ (last_summary ? `Summary: ${last_summary}\n` : "") +
585
+ metricsBlock +
586
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
587
+ `to maintain concurrency control.`
588
+ : `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
589
+ `(version: ${newVersion})\n` +
590
+ (last_summary ? `Last summary: ${last_summary}\n` : "") +
591
+ (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
592
+ (active_branch ? `Active branch: ${active_branch}\n` : "") +
593
+ `📊 Embedding generation queued for semantic search.\n` +
594
+ metricsBlock +
595
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
596
+ `to maintain concurrency control.`);
572
597
  return {
573
598
  content: [{
574
599
  type: "text",
@@ -587,7 +612,7 @@ export async function sessionLoadContextHandler(args) {
587
612
  if (getInferenceSnapshot().totalCalls === 0) {
588
613
  resetInferenceMetrics();
589
614
  }
590
- const { project, level = "standard", role } = args;
615
+ const { project, level = "standard", role, conversation_id: convId } = args;
591
616
  // T6 fix: explicit Number() coercion prevents string "2000" from later concatenating instead of adding
592
617
  const _maxTokensArg = Number(args.max_tokens);
593
618
  const _maxTokensSetting = parseInt(await getSetting("max_tokens", "0"), 10);
@@ -625,10 +650,21 @@ export async function sessionLoadContextHandler(args) {
625
650
  catch {
626
651
  debugLog(`[session_load_context] Fresh project skill injection failed — continuing without`);
627
652
  }
653
+ if (convId) {
654
+ const { markContextLoaded } = await import("../session/sessionContext.js");
655
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
656
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
657
+ }
658
+ const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
659
+ const boundariesHeader0 = `# OPERATING BOUNDARIES (v${BV0}) — enforced server-side, shown for transparency\n` +
660
+ BT0 + "\n\n" +
661
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
662
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
628
663
  return {
629
664
  content: [{
630
665
  type: "text",
631
- text: `No session context found for project "${project}" at level ${level}.\n` +
666
+ text: boundariesHeader0 +
667
+ `No session context found for project "${project}" at level ${level}.\n` +
632
668
  `This project has no previous session history. Starting fresh.` +
633
669
  freshSkillBlock,
634
670
  }],
@@ -1077,14 +1113,34 @@ export async function sessionLoadContextHandler(args) {
1077
1113
  if (droppedSections.length > 0) {
1078
1114
  responseText += `\n\n[ℹ️ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
1079
1115
  }
1116
+ if (convId) {
1117
+ const { markContextLoaded } = await import("../session/sessionContext.js");
1118
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1119
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
1120
+ }
1121
+ const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
1122
+ const boundariesHeader = `# OPERATING BOUNDARIES (v${BV}) — enforced server-side, shown for transparency\n` +
1123
+ BOUNDARIES_TEXT + "\n\n" +
1124
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1125
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1080
1126
  return {
1081
- content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
1127
+ content: [{ type: "text", text: boundariesHeader + responseText + MEMORY_BOUNDARY_SUFFIX }],
1082
1128
  isError: false,
1083
1129
  };
1084
1130
  }
1085
1131
  let responseText = criticalPrefix + lowerPriority + historySection;
1132
+ if (convId) {
1133
+ const { markContextLoaded } = await import("../session/sessionContext.js");
1134
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1135
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
1136
+ }
1137
+ const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
1138
+ const boundariesHeader2 = `# OPERATING BOUNDARIES (v${BV2}) — enforced server-side, shown for transparency\n` +
1139
+ BOUNDARIES_TEXT + "\n\n" +
1140
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1141
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1086
1142
  return {
1087
- content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
1143
+ content: [{ type: "text", text: boundariesHeader2 + responseText + MEMORY_BOUNDARY_SUFFIX }],
1088
1144
  isError: false,
1089
1145
  };
1090
1146
  }
@@ -149,6 +149,8 @@ export function isPrismInferArgs(args) {
149
149
  return false;
150
150
  if (a.think !== undefined && typeof a.think !== "boolean")
151
151
  return false;
152
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
153
+ return false;
152
154
  if (a.verify !== undefined && typeof a.verify !== "boolean")
153
155
  return false;
154
156
  if (a.verifier_model !== undefined && typeof a.verifier_model !== "string")
@@ -665,6 +667,17 @@ export async function prismInferHandler(args) {
665
667
  // T4: pass prompt_text so recordInference computes submittedEst via
666
668
  // estimateTokens() — critical for cloud path where prompt_tokens is unset.
667
669
  recordInference({ ...result, prompt_text: args.prompt });
670
+ // Best-effort session telemetry — records that inference ran for this
671
+ // conversation. Never affects routing or safety decisions.
672
+ const _convId = args.conversation_id;
673
+ if (_convId && result.backend !== "safety_gate") {
674
+ import("../session/sessionContext.js").then(({ noteInferenceForSession }) => {
675
+ noteInferenceForSession(_convId, {
676
+ backend: result.backend,
677
+ usedCloud: result.used_cloud,
678
+ });
679
+ }).catch(() => { });
680
+ }
668
681
  // Best-effort portal forwarding (independent analytics stream).
669
682
  // safety_gate excluded — logging crisis filter triggers is a HIPAA concern.
670
683
  if (result.backend !== "safety_gate") {
@@ -99,6 +99,10 @@ export const SESSION_SAVE_HANDOFF_TOOL = {
99
99
  type: "boolean",
100
100
  description: "Set to true to disable automatic CRDT merging and fail strictly on version conflict (original OCC behavior). Default: false.",
101
101
  },
102
+ conversation_id: {
103
+ 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 was called for this conversation before accepting the write.",
105
+ },
102
106
  },
103
107
  required: ["project"],
104
108
  },
@@ -141,6 +145,10 @@ export const SESSION_LOAD_CONTEXT_TOOL = {
141
145
  type: "string",
142
146
  description: "Brief 2-5 word noun phrase describing what this tool call is about.",
143
147
  },
148
+ conversation_id: {
149
+ type: "string",
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
+ },
144
152
  },
145
153
  required: ["project", "toolAction", "toolSummary"],
146
154
  },
@@ -509,6 +517,8 @@ export function isSessionSaveHandoffArgs(args) {
509
517
  return false;
510
518
  if (a.disable_merge !== undefined && typeof a.disable_merge !== "boolean")
511
519
  return false;
520
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
521
+ return false;
512
522
  return true;
513
523
  }
514
524
  // ─── v0.4.0: Type guard for semantic search ──────────────────
@@ -572,6 +582,8 @@ export function isSessionLoadContextArgs(args) {
572
582
  return false;
573
583
  if (a.toolSummary !== undefined && typeof a.toolSummary !== "string")
574
584
  return false;
585
+ if (a.conversation_id !== undefined && typeof a.conversation_id !== "string")
586
+ return false;
575
587
  return true;
576
588
  }
577
589
  // ─── v2.0: Time Travel Tool Definitions ──────────────────────
@@ -48,10 +48,15 @@ async function ensureTable() {
48
48
  `);
49
49
  _tableReady = true;
50
50
  }
51
- /** Reset DB connection (for tests). */
51
+ /** Reset DB connection and in-memory buffer (for tests). */
52
52
  export function _resetDb() {
53
53
  _db = null;
54
54
  _tableReady = false;
55
+ BUFFER.length = 0;
56
+ if (flushTimer) {
57
+ clearTimeout(flushTimer);
58
+ flushTimer = null;
59
+ }
55
60
  }
56
61
  // ─── In-Memory Buffer ────────────────────────────────────────
57
62
  const BUFFER = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "19.2.9",
3
+ "version": "19.3.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",