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.
- package/dist/boundaries/__tests__/boundaries.test.js +58 -0
- package/dist/boundaries/boundaries.js +48 -0
- package/dist/session/__tests__/sessionContext.test.js +133 -0
- package/dist/session/sessionContext.js +165 -0
- package/dist/tools/__tests__/ledgerHandlers.test.js +94 -0
- package/dist/tools/ledgerHandlers.js +77 -21
- package/dist/tools/prismInferHandler.js +13 -0
- package/dist/tools/sessionMemoryDefinitions.js +12 -0
- package/dist/utils/analytics.js +6 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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 =
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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:
|
|
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 ──────────────────────
|
package/dist/utils/analytics.js
CHANGED
|
@@ -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.
|
|
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",
|