prism-mcp-server 19.2.8 → 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/README.md +3 -1
- package/dist/boundaries/__tests__/boundaries.test.js +58 -0
- package/dist/boundaries/boundaries.js +48 -0
- package/dist/scm/types.js +1 -4
- package/dist/session/__tests__/sessionContext.test.js +133 -0
- package/dist/session/sessionContext.js +165 -0
- package/dist/storage/synalux.js +1 -1
- package/dist/tools/__tests__/ingestHandler.test.js +19 -13
- package/dist/tools/__tests__/layer1Integration.test.js +357 -0
- package/dist/tools/__tests__/ledgerHandlers.test.js +125 -28
- package/dist/tools/ledgerHandlers.js +158 -35
- package/dist/tools/prismInferHandler.js +177 -22
- package/dist/tools/sessionDriftHandler.js +1 -1
- package/dist/tools/sessionMemoryDefinitions.js +12 -0
- package/dist/tools/skillRouting.js +22 -9
- package/dist/utils/analytics.js +6 -1
- package/dist/utils/entitlements.js +9 -0
- package/dist/utils/inferenceMetrics.js +89 -21
- package/dist/utils/layer1.js +110 -0
- package/dist/utils/projectResolver.js +2 -4
- package/dist/utils/qualityGate.js +19 -3
- package/dist/utils/safetyGate.js +22 -0
- package/dist/vm/competitorImport.js +1 -1
- package/dist/vm/componentMarketplace.js +1 -1
- package/dist/vm/creativeStudio.js +1 -1
- package/dist/vm/ethicsEnforcement.js +2 -2
- package/dist/vm/gameEngine.js +1 -1
- package/dist/vm/projectTemplates.js +1 -1
- package/dist/vm/types.js +1 -2
- package/dist/vm/vmManager.js +1 -1
- package/dist/vm/workspaceLicensing.js +1 -1
- package/package.json +1 -1
|
@@ -54,6 +54,7 @@ vi.mock("../../../src/config.js", () => ({
|
|
|
54
54
|
PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP: 25,
|
|
55
55
|
PRISM_ACTR_ENABLED: false,
|
|
56
56
|
PRISM_ACTR_ACCESS_LOG_RETENTION_DAYS: 90,
|
|
57
|
+
SYNALUX_CONFIGURED: false,
|
|
57
58
|
}));
|
|
58
59
|
vi.mock("../../../src/utils/logger.js", () => ({
|
|
59
60
|
debugLog: vi.fn(),
|
|
@@ -120,6 +121,19 @@ vi.mock("../../../src/utils/cognitiveMemory.js", () => ({
|
|
|
120
121
|
computeEffectiveImportance: vi.fn((imp) => imp),
|
|
121
122
|
recordMemoryAccess: vi.fn(),
|
|
122
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
|
+
}));
|
|
123
137
|
vi.mock("../../../src/tools/commonHelpers.js", () => ({
|
|
124
138
|
redactSettings: vi.fn((s) => s),
|
|
125
139
|
toMarkdown: vi.fn(() => "# Markdown Export"),
|
|
@@ -132,6 +146,7 @@ vi.mock("../../../src/utils/vaultExporter.js", () => ({
|
|
|
132
146
|
// ======================================================================
|
|
133
147
|
import { getStorage } from "../../../src/storage/index.js";
|
|
134
148
|
import { getSetting, getAllSettings } from "../../../src/storage/configStorage.js";
|
|
149
|
+
import { requireContextLoaded, markContextLoaded } from "../../../src/session/sessionContext.js";
|
|
135
150
|
import { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler, memoryHistoryHandler, sessionSaveImageHandler, sessionViewImageHandler, sanitizeMemoryInput, } from "../../../src/tools/ledgerHandlers.js";
|
|
136
151
|
const mockGetStorage = vi.mocked(getStorage);
|
|
137
152
|
const mockGetSetting = vi.mocked(getSetting);
|
|
@@ -328,6 +343,36 @@ describe("ledgerHandlers", () => {
|
|
|
328
343
|
storage.saveLedger.mockRejectedValue(new Error("DB write failed"));
|
|
329
344
|
await expect(sessionSaveLedgerHandler(validArgs)).rejects.toThrow("DB write failed");
|
|
330
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
|
+
});
|
|
331
376
|
});
|
|
332
377
|
// ====================================================================
|
|
333
378
|
// 3. sessionLoadContextHandler
|
|
@@ -427,7 +472,7 @@ describe("ledgerHandlers", () => {
|
|
|
427
472
|
});
|
|
428
473
|
const text = result.content[0].text;
|
|
429
474
|
// With 100 tokens * 4 chars = 400 char budget, the 5000-char summary gets truncated
|
|
430
|
-
expect(text).toContain("
|
|
475
|
+
expect(text).toContain("Sections omitted to fit token budget");
|
|
431
476
|
});
|
|
432
477
|
it("includes recent sessions in formatted output", async () => {
|
|
433
478
|
storage.loadContext.mockResolvedValue({
|
|
@@ -472,6 +517,24 @@ describe("ledgerHandlers", () => {
|
|
|
472
517
|
storage.loadContext.mockRejectedValue(new Error("Connection timeout"));
|
|
473
518
|
await expect(sessionLoadContextHandler(validArgs)).rejects.toThrow("Connection timeout");
|
|
474
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
|
+
});
|
|
475
538
|
});
|
|
476
539
|
// ====================================================================
|
|
477
540
|
// 4. sessionSaveHandoffHandler
|
|
@@ -605,6 +668,38 @@ describe("ledgerHandlers", () => {
|
|
|
605
668
|
storage.saveHandoff.mockRejectedValue(new Error("Write conflict"));
|
|
606
669
|
await expect(sessionSaveHandoffHandler(validArgs)).rejects.toThrow("Write conflict");
|
|
607
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
|
+
});
|
|
608
703
|
});
|
|
609
704
|
// ====================================================================
|
|
610
705
|
// 5. memoryHistoryHandler
|
|
@@ -677,36 +772,36 @@ describe("ledgerHandlers", () => {
|
|
|
677
772
|
describe("sessionForgetMemoryHandler", () => {
|
|
678
773
|
it("soft-deletes a memory entry by default", async () => {
|
|
679
774
|
const result = await sessionForgetMemoryHandler({
|
|
680
|
-
memory_id: "
|
|
775
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440123",
|
|
681
776
|
});
|
|
682
777
|
expect(result.isError).toBe(false);
|
|
683
778
|
expect(result.content[0].text).toContain("Soft Deleted");
|
|
684
|
-
expect(result.content[0].text).toContain("
|
|
685
|
-
expect(storage.softDeleteLedger).toHaveBeenCalledWith("
|
|
779
|
+
expect(result.content[0].text).toContain("550e8400-e29b-41d4-a716-446655440123");
|
|
780
|
+
expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440123", "test-user-id", undefined);
|
|
686
781
|
});
|
|
687
782
|
it("soft-deletes with reason for audit trail", async () => {
|
|
688
783
|
const result = await sessionForgetMemoryHandler({
|
|
689
|
-
memory_id: "
|
|
784
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440456",
|
|
690
785
|
reason: "GDPR Article 17 request",
|
|
691
786
|
});
|
|
692
787
|
expect(result.isError).toBe(false);
|
|
693
788
|
expect(result.content[0].text).toContain("Reason");
|
|
694
789
|
expect(result.content[0].text).toContain("GDPR Article 17 request");
|
|
695
|
-
expect(storage.softDeleteLedger).toHaveBeenCalledWith("
|
|
790
|
+
expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440456", "test-user-id", "GDPR Article 17 request");
|
|
696
791
|
});
|
|
697
792
|
it("hard-deletes when hard_delete is true", async () => {
|
|
698
793
|
const result = await sessionForgetMemoryHandler({
|
|
699
|
-
memory_id: "
|
|
794
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440789",
|
|
700
795
|
hard_delete: true,
|
|
701
796
|
});
|
|
702
797
|
expect(result.isError).toBe(false);
|
|
703
798
|
expect(result.content[0].text).toContain("Hard Deleted");
|
|
704
799
|
expect(result.content[0].text).toContain("permanently removed");
|
|
705
|
-
expect(storage.hardDeleteLedger).toHaveBeenCalledWith("
|
|
800
|
+
expect(storage.hardDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440789", "test-user-id");
|
|
706
801
|
});
|
|
707
802
|
it("does not call hardDeleteLedger when hard_delete is false", async () => {
|
|
708
803
|
await sessionForgetMemoryHandler({
|
|
709
|
-
memory_id: "
|
|
804
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440aaa",
|
|
710
805
|
hard_delete: false,
|
|
711
806
|
});
|
|
712
807
|
expect(storage.hardDeleteLedger).not.toHaveBeenCalled();
|
|
@@ -731,7 +826,7 @@ describe("ledgerHandlers", () => {
|
|
|
731
826
|
it("catches storage errors and returns isError (never throws)", async () => {
|
|
732
827
|
storage.softDeleteLedger.mockRejectedValue(new Error("Entry not found"));
|
|
733
828
|
const result = await sessionForgetMemoryHandler({
|
|
734
|
-
memory_id: "
|
|
829
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440f99",
|
|
735
830
|
});
|
|
736
831
|
expect(result.isError).toBe(true);
|
|
737
832
|
expect(result.content[0].text).toContain("Entry not found");
|
|
@@ -739,7 +834,7 @@ describe("ledgerHandlers", () => {
|
|
|
739
834
|
it("catches hard_delete storage errors gracefully", async () => {
|
|
740
835
|
storage.hardDeleteLedger.mockRejectedValue(new Error("FK constraint"));
|
|
741
836
|
const result = await sessionForgetMemoryHandler({
|
|
742
|
-
memory_id: "
|
|
837
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440f11",
|
|
743
838
|
hard_delete: true,
|
|
744
839
|
});
|
|
745
840
|
expect(result.isError).toBe(true);
|
|
@@ -753,6 +848,7 @@ describe("ledgerHandlers", () => {
|
|
|
753
848
|
let tempDir;
|
|
754
849
|
beforeEach(async () => {
|
|
755
850
|
tempDir = await mkdtemp(join(tmpdir(), "prism-handler-export-"));
|
|
851
|
+
process.env.PRISM_EXPORT_ROOT = tempDir;
|
|
756
852
|
storage.listProjects.mockResolvedValue(["test-project"]);
|
|
757
853
|
storage.getLedgerEntries.mockResolvedValue([
|
|
758
854
|
{ id: "entry-1", summary: "Session 1", importance: 3 },
|
|
@@ -763,6 +859,7 @@ describe("ledgerHandlers", () => {
|
|
|
763
859
|
});
|
|
764
860
|
});
|
|
765
861
|
afterEach(async () => {
|
|
862
|
+
delete process.env.PRISM_EXPORT_ROOT;
|
|
766
863
|
await rm(tempDir, { recursive: true, force: true });
|
|
767
864
|
});
|
|
768
865
|
it("exports JSON file for a single project", async () => {
|
|
@@ -832,7 +929,7 @@ describe("ledgerHandlers", () => {
|
|
|
832
929
|
});
|
|
833
930
|
afterEach(async () => {
|
|
834
931
|
// Clean up vault directory if created
|
|
835
|
-
const vaultDir = join(os.homedir(), ".prism-mcp", "media", "
|
|
932
|
+
const vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
|
|
836
933
|
if (fs.existsSync(vaultDir)) {
|
|
837
934
|
await rm(vaultDir, { recursive: true, force: true });
|
|
838
935
|
}
|
|
@@ -840,7 +937,7 @@ describe("ledgerHandlers", () => {
|
|
|
840
937
|
});
|
|
841
938
|
it("saves an image and returns success with image ID", async () => {
|
|
842
939
|
const result = await sessionSaveImageHandler({
|
|
843
|
-
project: "
|
|
940
|
+
project: "__test-img__",
|
|
844
941
|
file_path: testImagePath,
|
|
845
942
|
description: "Dashboard screenshot",
|
|
846
943
|
});
|
|
@@ -851,7 +948,7 @@ describe("ledgerHandlers", () => {
|
|
|
851
948
|
});
|
|
852
949
|
it("updates handoff metadata with visual memory entry", async () => {
|
|
853
950
|
await sessionSaveImageHandler({
|
|
854
|
-
project: "
|
|
951
|
+
project: "__test-img__",
|
|
855
952
|
file_path: testImagePath,
|
|
856
953
|
description: "UI mockup",
|
|
857
954
|
});
|
|
@@ -862,8 +959,8 @@ describe("ledgerHandlers", () => {
|
|
|
862
959
|
});
|
|
863
960
|
it("returns error for non-existent file", async () => {
|
|
864
961
|
const result = await sessionSaveImageHandler({
|
|
865
|
-
project: "
|
|
866
|
-
file_path: "
|
|
962
|
+
project: "__test-img__",
|
|
963
|
+
file_path: join(tempDir, "does-not-exist.png"),
|
|
867
964
|
description: "Missing image",
|
|
868
965
|
});
|
|
869
966
|
expect(result.isError).toBe(true);
|
|
@@ -873,7 +970,7 @@ describe("ledgerHandlers", () => {
|
|
|
873
970
|
const bmpPath = join(tempDir, "test.bmp");
|
|
874
971
|
fs.writeFileSync(bmpPath, Buffer.from([0x42, 0x4d]));
|
|
875
972
|
const result = await sessionSaveImageHandler({
|
|
876
|
-
project: "
|
|
973
|
+
project: "__test-img__",
|
|
877
974
|
file_path: bmpPath,
|
|
878
975
|
description: "BMP image",
|
|
879
976
|
});
|
|
@@ -883,7 +980,7 @@ describe("ledgerHandlers", () => {
|
|
|
883
980
|
it("returns error when no active context exists", async () => {
|
|
884
981
|
storage.loadContext.mockResolvedValue(null);
|
|
885
982
|
const result = await sessionSaveImageHandler({
|
|
886
|
-
project: "
|
|
983
|
+
project: "__test-img__",
|
|
887
984
|
file_path: testImagePath,
|
|
888
985
|
description: "No context image",
|
|
889
986
|
});
|
|
@@ -893,7 +990,7 @@ describe("ledgerHandlers", () => {
|
|
|
893
990
|
// --- Input Validation ---
|
|
894
991
|
it("returns error for invalid args (missing required fields)", async () => {
|
|
895
992
|
const result = await sessionSaveImageHandler({
|
|
896
|
-
project: "
|
|
993
|
+
project: "__test-img__",
|
|
897
994
|
});
|
|
898
995
|
expect(result.isError).toBe(true);
|
|
899
996
|
expect(result.content[0].text).toContain("Invalid arguments");
|
|
@@ -912,7 +1009,7 @@ describe("ledgerHandlers", () => {
|
|
|
912
1009
|
let vaultImagePath;
|
|
913
1010
|
beforeEach(async () => {
|
|
914
1011
|
tempDir = await mkdtemp(join(tmpdir(), "prism-view-image-test-"));
|
|
915
|
-
vaultDir = join(os.homedir(), ".prism-mcp", "media", "
|
|
1012
|
+
vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
|
|
916
1013
|
fs.mkdirSync(vaultDir, { recursive: true });
|
|
917
1014
|
// Create a test image in the vault
|
|
918
1015
|
vaultImagePath = join(vaultDir, "abc12345.png");
|
|
@@ -941,7 +1038,7 @@ describe("ledgerHandlers", () => {
|
|
|
941
1038
|
},
|
|
942
1039
|
});
|
|
943
1040
|
const result = await sessionViewImageHandler({
|
|
944
|
-
project: "
|
|
1041
|
+
project: "__test-img__",
|
|
945
1042
|
image_id: "abc12345",
|
|
946
1043
|
});
|
|
947
1044
|
expect(result.isError).toBe(false);
|
|
@@ -964,7 +1061,7 @@ describe("ledgerHandlers", () => {
|
|
|
964
1061
|
},
|
|
965
1062
|
});
|
|
966
1063
|
const result = await sessionViewImageHandler({
|
|
967
|
-
project: "
|
|
1064
|
+
project: "__test-img__",
|
|
968
1065
|
image_id: "nonexistent",
|
|
969
1066
|
});
|
|
970
1067
|
expect(result.isError).toBe(true);
|
|
@@ -976,7 +1073,7 @@ describe("ledgerHandlers", () => {
|
|
|
976
1073
|
metadata: {},
|
|
977
1074
|
});
|
|
978
1075
|
const result = await sessionViewImageHandler({
|
|
979
|
-
project: "
|
|
1076
|
+
project: "__test-img__",
|
|
980
1077
|
image_id: "any-id",
|
|
981
1078
|
});
|
|
982
1079
|
expect(result.isError).toBe(true);
|
|
@@ -997,7 +1094,7 @@ describe("ledgerHandlers", () => {
|
|
|
997
1094
|
},
|
|
998
1095
|
});
|
|
999
1096
|
const result = await sessionViewImageHandler({
|
|
1000
|
-
project: "
|
|
1097
|
+
project: "__test-img__",
|
|
1001
1098
|
image_id: "abc12345",
|
|
1002
1099
|
});
|
|
1003
1100
|
expect(result.isError).toBe(true);
|
|
@@ -1018,7 +1115,7 @@ describe("ledgerHandlers", () => {
|
|
|
1018
1115
|
},
|
|
1019
1116
|
});
|
|
1020
1117
|
const result = await sessionViewImageHandler({
|
|
1021
|
-
project: "
|
|
1118
|
+
project: "__test-img__",
|
|
1022
1119
|
image_id: "abc12345",
|
|
1023
1120
|
});
|
|
1024
1121
|
expect(result.isError).toBe(false);
|
|
@@ -1028,7 +1125,7 @@ describe("ledgerHandlers", () => {
|
|
|
1028
1125
|
// --- Input Validation ---
|
|
1029
1126
|
it("returns error for invalid args (missing image_id)", async () => {
|
|
1030
1127
|
const result = await sessionViewImageHandler({
|
|
1031
|
-
project: "
|
|
1128
|
+
project: "__test-img__",
|
|
1032
1129
|
});
|
|
1033
1130
|
expect(result.isError).toBe(true);
|
|
1034
1131
|
});
|
|
@@ -1154,13 +1251,13 @@ describe("ledgerHandlers", () => {
|
|
|
1154
1251
|
});
|
|
1155
1252
|
it("sessionForgetMemoryHandler includes 'tombstoned' in soft-delete response", async () => {
|
|
1156
1253
|
const result = await sessionForgetMemoryHandler({
|
|
1157
|
-
memory_id: "
|
|
1254
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
1158
1255
|
});
|
|
1159
1256
|
expect(result.content[0].text).toContain("tombstoned");
|
|
1160
1257
|
});
|
|
1161
1258
|
it("sessionForgetMemoryHandler mentions hard_delete option in soft-delete response", async () => {
|
|
1162
1259
|
const result = await sessionForgetMemoryHandler({
|
|
1163
|
-
memory_id: "
|
|
1260
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
1164
1261
|
});
|
|
1165
1262
|
expect(result.content[0].text).toContain("hard_delete: true");
|
|
1166
1263
|
});
|
|
@@ -89,11 +89,24 @@ const MEMORY_BOUNDARY_SUFFIX = '\n</prism_memory>';
|
|
|
89
89
|
* After saving, generates an embedding vector for the entry via fire-and-forget.
|
|
90
90
|
*/
|
|
91
91
|
import { computeEffectiveImportance, recordMemoryAccess } from "../utils/cognitiveMemory.js";
|
|
92
|
-
import { formatInferenceMetrics, resetInferenceMetrics } from "../utils/inferenceMetrics.js";
|
|
92
|
+
import { formatInferenceMetrics, resetInferenceMetrics, getInferenceSnapshot } from "../utils/inferenceMetrics.js";
|
|
93
93
|
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",
|
|
@@ -581,10 +606,17 @@ export async function sessionLoadContextHandler(args) {
|
|
|
581
606
|
if (!isSessionLoadContextArgs(args)) {
|
|
582
607
|
throw new Error("Invalid arguments for session_load_context");
|
|
583
608
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
609
|
+
// T3 fix: only reset metrics at true session start (totalCalls==0), not on GATE 5 mid-session
|
|
610
|
+
// reloads. Resetting on every call wipes accumulated delegation metrics right before
|
|
611
|
+
// session_save_ledger renders them, making the 📊 block show near-zero after any reload.
|
|
612
|
+
if (getInferenceSnapshot().totalCalls === 0) {
|
|
613
|
+
resetInferenceMetrics();
|
|
614
|
+
}
|
|
615
|
+
const { project, level = "standard", role, conversation_id: convId } = args;
|
|
616
|
+
// T6 fix: explicit Number() coercion prevents string "2000" from later concatenating instead of adding
|
|
617
|
+
const _maxTokensArg = Number(args.max_tokens);
|
|
618
|
+
const _maxTokensSetting = parseInt(await getSetting("max_tokens", "0"), 10);
|
|
619
|
+
const maxTokens = (_maxTokensArg > 0 ? _maxTokensArg : undefined) ?? (_maxTokensSetting > 0 ? _maxTokensSetting : undefined);
|
|
588
620
|
const agentName = await getSetting("agent_name", "");
|
|
589
621
|
const validLevels = ["quick", "standard", "deep"];
|
|
590
622
|
if (!validLevels.includes(level)) {
|
|
@@ -600,12 +632,41 @@ export async function sessionLoadContextHandler(args) {
|
|
|
600
632
|
const storage = await getStorage();
|
|
601
633
|
const effectiveRole = role || await getSetting("default_role", "") || undefined;
|
|
602
634
|
const data = await storage.loadContext(project, level, PRISM_USER_ID, effectiveRole); // v3.0: role with dashboard fallback
|
|
635
|
+
// F4 fix: inject protected skills even for fresh projects.
|
|
636
|
+
// Previously this returned before skill injection, leaving new projects with zero
|
|
637
|
+
// behavioral guardrails for the entire session. Now protected skills always load.
|
|
603
638
|
if (!data) {
|
|
639
|
+
let freshSkillBlock = "";
|
|
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()}`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
debugLog(`[session_load_context] Fresh project skill injection failed — continuing without`);
|
|
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`;
|
|
604
663
|
return {
|
|
605
664
|
content: [{
|
|
606
665
|
type: "text",
|
|
607
|
-
text:
|
|
608
|
-
`
|
|
666
|
+
text: boundariesHeader0 +
|
|
667
|
+
`No session context found for project "${project}" at level ${level}.\n` +
|
|
668
|
+
`This project has no previous session history. Starting fresh.` +
|
|
669
|
+
freshSkillBlock,
|
|
609
670
|
}],
|
|
610
671
|
isError: false,
|
|
611
672
|
};
|
|
@@ -855,6 +916,10 @@ export async function sessionLoadContextHandler(args) {
|
|
|
855
916
|
const resolved = await resolveSkillsForProject(project);
|
|
856
917
|
const sortedSkills = resolved.skills;
|
|
857
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
|
+
}
|
|
858
923
|
let synaluxContent = {};
|
|
859
924
|
if (SYNALUX_CONFIGURED && storage && typeof storage.fetchSkillContent === "function") {
|
|
860
925
|
const missing = sortedSkills.map(s => s.name).filter(n => !loadedSkills.includes(n));
|
|
@@ -994,12 +1059,8 @@ export async function sessionLoadContextHandler(args) {
|
|
|
994
1059
|
behavWarnings.map(w => `- ${w.summary} (importance: ${w.importance})`).join("\n");
|
|
995
1060
|
behavBlock = [...rawBlock].slice(0, 2000).join('');
|
|
996
1061
|
}
|
|
997
|
-
let responseText = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n${formattedContext.trim()}${splitBrainWarning}${driftReport}${briefingBlock}${sdmRecallBlock}${greetingBlock}${visualMemoryBlock}${behavBlock}${skillBlock}${versionNote}`;
|
|
998
1062
|
// ─── v9.4.7: ABA Precision Protocol (foundational) ────────
|
|
999
|
-
|
|
1000
|
-
// under these behavioral rules. Never truncated (placed before
|
|
1001
|
-
// token budget check).
|
|
1002
|
-
responseText += `\n\n[🧠 ABA PRECISION PROTOCOL]\n` +
|
|
1063
|
+
const abaProtocol = `\n\n[🧠 ABA PRECISION PROTOCOL]\n` +
|
|
1003
1064
|
`Rule 1 — Observable Goals: Every task must have a measurable, verifiable outcome. State the specific result.\n` +
|
|
1004
1065
|
`Rule 2 — Precise Execution: One step at a time. Verify each step. If it fails → STOP → fix → verify → then continue.\n` +
|
|
1005
1066
|
`Rule 3 — No Reinforcement of Errors: Never repeat the same mistake twice. When the user says something is wrong, read the actual code/data FIRST before forming an opinion.\n` +
|
|
@@ -1007,17 +1068,79 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1007
1068
|
`Rule 5 — Fix Without Asking: When you see a bug, fix it immediately. Do NOT ask "would you like me to fix that?" — just fix it.\n` +
|
|
1008
1069
|
`Rule 6 — Action Intent: When user says "fix/run/open/deploy", they want ACTION not a tutorial. Ask for specific info needed in 1-2 sentences, or act directly.\n` +
|
|
1009
1070
|
`Rule 7 — Tool Redirect: When user asks to "open browser"/"run terminal"/"git push" — output ONLY the URL or command. No follow-up. No explanations. Example: "open browser" → "https://synalux.ai/dashboard"`;
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1071
|
+
// T5 structural truncation: assemble sections in priority order so budget truncation
|
|
1072
|
+
// drops the LEAST critical sections first (session history), never skills.
|
|
1073
|
+
// Priority high→low: ABA | skills | behavioral warnings | version | drift | briefing | SDM | history
|
|
1074
|
+
// The header + critical rules are always at the top; history is appended last.
|
|
1075
|
+
const criticalPrefix = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n` +
|
|
1076
|
+
abaProtocol +
|
|
1077
|
+
behavBlock +
|
|
1078
|
+
skillBlock +
|
|
1079
|
+
versionNote +
|
|
1080
|
+
greetingBlock +
|
|
1081
|
+
splitBrainWarning;
|
|
1082
|
+
const lowerPriority = driftReport +
|
|
1083
|
+
briefingBlock +
|
|
1084
|
+
sdmRecallBlock +
|
|
1085
|
+
visualMemoryBlock;
|
|
1086
|
+
const historySection = formattedContext.trim()
|
|
1087
|
+
? `\n\n---\n\n📋 Historical Context:\n${formattedContext.trim()}`
|
|
1088
|
+
: "";
|
|
1089
|
+
// ─── v4.0: Token Budget Truncation (T5 structural version) ──
|
|
1090
|
+
// Drops low-priority sections whole (no mid-slice) before falling back
|
|
1091
|
+
// to trimming the history block. Protected sections (skills, ABA) always survive.
|
|
1092
|
+
// T1: use ~3.5 chars/token (better for emoji+code payload) instead of flat 4.
|
|
1012
1093
|
if (maxTokens && maxTokens > 0) {
|
|
1013
|
-
const maxChars = maxTokens *
|
|
1094
|
+
const maxChars = Math.floor(maxTokens * 3.5);
|
|
1095
|
+
const droppedSections = [];
|
|
1096
|
+
let responseText = criticalPrefix + lowerPriority + historySection;
|
|
1014
1097
|
if (responseText.length > maxChars) {
|
|
1015
|
-
|
|
1016
|
-
|
|
1098
|
+
// Drop low-priority sections first (SDM recall, briefing, drift)
|
|
1099
|
+
responseText = criticalPrefix + historySection;
|
|
1100
|
+
droppedSections.push("briefing", "drift-report", "sdm-recall");
|
|
1101
|
+
debugLog(`[session_load_context] T5: dropped low-priority sections (${droppedSections.join(", ")})`);
|
|
1102
|
+
if (responseText.length > maxChars) {
|
|
1103
|
+
// History too long — trim it, preserving all critical sections
|
|
1104
|
+
const histAllowed = Math.max(0, maxChars - criticalPrefix.length - 200);
|
|
1105
|
+
const trimmedHistory = histAllowed > 0
|
|
1106
|
+
? `\n\n---\n\n📋 Historical Context (trimmed):\n${formattedContext.trim().slice(0, histAllowed)}…`
|
|
1107
|
+
: "";
|
|
1108
|
+
droppedSections.push("partial-history");
|
|
1109
|
+
responseText = criticalPrefix + trimmedHistory;
|
|
1110
|
+
debugLog(`[session_load_context] T5: trimmed history to ${histAllowed} chars`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (droppedSections.length > 0) {
|
|
1114
|
+
responseText += `\n\n[ℹ️ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
|
|
1017
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`;
|
|
1126
|
+
return {
|
|
1127
|
+
content: [{ type: "text", text: boundariesHeader + responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1128
|
+
isError: false,
|
|
1129
|
+
};
|
|
1018
1130
|
}
|
|
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`;
|
|
1019
1142
|
return {
|
|
1020
|
-
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1143
|
+
content: [{ type: "text", text: boundariesHeader2 + responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1021
1144
|
isError: false,
|
|
1022
1145
|
};
|
|
1023
1146
|
}
|