prism-mcp-server 19.2.8 → 19.2.9
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/scm/types.js +1 -4
- 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 +31 -28
- package/dist/tools/ledgerHandlers.js +82 -15
- package/dist/tools/prismInferHandler.js +164 -22
- package/dist/tools/sessionDriftHandler.js +1 -1
- package/dist/tools/skillRouting.js +22 -9
- 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(),
|
|
@@ -427,7 +428,7 @@ describe("ledgerHandlers", () => {
|
|
|
427
428
|
});
|
|
428
429
|
const text = result.content[0].text;
|
|
429
430
|
// With 100 tokens * 4 chars = 400 char budget, the 5000-char summary gets truncated
|
|
430
|
-
expect(text).toContain("
|
|
431
|
+
expect(text).toContain("Sections omitted to fit token budget");
|
|
431
432
|
});
|
|
432
433
|
it("includes recent sessions in formatted output", async () => {
|
|
433
434
|
storage.loadContext.mockResolvedValue({
|
|
@@ -677,36 +678,36 @@ describe("ledgerHandlers", () => {
|
|
|
677
678
|
describe("sessionForgetMemoryHandler", () => {
|
|
678
679
|
it("soft-deletes a memory entry by default", async () => {
|
|
679
680
|
const result = await sessionForgetMemoryHandler({
|
|
680
|
-
memory_id: "
|
|
681
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440123",
|
|
681
682
|
});
|
|
682
683
|
expect(result.isError).toBe(false);
|
|
683
684
|
expect(result.content[0].text).toContain("Soft Deleted");
|
|
684
|
-
expect(result.content[0].text).toContain("
|
|
685
|
-
expect(storage.softDeleteLedger).toHaveBeenCalledWith("
|
|
685
|
+
expect(result.content[0].text).toContain("550e8400-e29b-41d4-a716-446655440123");
|
|
686
|
+
expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440123", "test-user-id", undefined);
|
|
686
687
|
});
|
|
687
688
|
it("soft-deletes with reason for audit trail", async () => {
|
|
688
689
|
const result = await sessionForgetMemoryHandler({
|
|
689
|
-
memory_id: "
|
|
690
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440456",
|
|
690
691
|
reason: "GDPR Article 17 request",
|
|
691
692
|
});
|
|
692
693
|
expect(result.isError).toBe(false);
|
|
693
694
|
expect(result.content[0].text).toContain("Reason");
|
|
694
695
|
expect(result.content[0].text).toContain("GDPR Article 17 request");
|
|
695
|
-
expect(storage.softDeleteLedger).toHaveBeenCalledWith("
|
|
696
|
+
expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440456", "test-user-id", "GDPR Article 17 request");
|
|
696
697
|
});
|
|
697
698
|
it("hard-deletes when hard_delete is true", async () => {
|
|
698
699
|
const result = await sessionForgetMemoryHandler({
|
|
699
|
-
memory_id: "
|
|
700
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440789",
|
|
700
701
|
hard_delete: true,
|
|
701
702
|
});
|
|
702
703
|
expect(result.isError).toBe(false);
|
|
703
704
|
expect(result.content[0].text).toContain("Hard Deleted");
|
|
704
705
|
expect(result.content[0].text).toContain("permanently removed");
|
|
705
|
-
expect(storage.hardDeleteLedger).toHaveBeenCalledWith("
|
|
706
|
+
expect(storage.hardDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440789", "test-user-id");
|
|
706
707
|
});
|
|
707
708
|
it("does not call hardDeleteLedger when hard_delete is false", async () => {
|
|
708
709
|
await sessionForgetMemoryHandler({
|
|
709
|
-
memory_id: "
|
|
710
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440aaa",
|
|
710
711
|
hard_delete: false,
|
|
711
712
|
});
|
|
712
713
|
expect(storage.hardDeleteLedger).not.toHaveBeenCalled();
|
|
@@ -731,7 +732,7 @@ describe("ledgerHandlers", () => {
|
|
|
731
732
|
it("catches storage errors and returns isError (never throws)", async () => {
|
|
732
733
|
storage.softDeleteLedger.mockRejectedValue(new Error("Entry not found"));
|
|
733
734
|
const result = await sessionForgetMemoryHandler({
|
|
734
|
-
memory_id: "
|
|
735
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440f99",
|
|
735
736
|
});
|
|
736
737
|
expect(result.isError).toBe(true);
|
|
737
738
|
expect(result.content[0].text).toContain("Entry not found");
|
|
@@ -739,7 +740,7 @@ describe("ledgerHandlers", () => {
|
|
|
739
740
|
it("catches hard_delete storage errors gracefully", async () => {
|
|
740
741
|
storage.hardDeleteLedger.mockRejectedValue(new Error("FK constraint"));
|
|
741
742
|
const result = await sessionForgetMemoryHandler({
|
|
742
|
-
memory_id: "
|
|
743
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440f11",
|
|
743
744
|
hard_delete: true,
|
|
744
745
|
});
|
|
745
746
|
expect(result.isError).toBe(true);
|
|
@@ -753,6 +754,7 @@ describe("ledgerHandlers", () => {
|
|
|
753
754
|
let tempDir;
|
|
754
755
|
beforeEach(async () => {
|
|
755
756
|
tempDir = await mkdtemp(join(tmpdir(), "prism-handler-export-"));
|
|
757
|
+
process.env.PRISM_EXPORT_ROOT = tempDir;
|
|
756
758
|
storage.listProjects.mockResolvedValue(["test-project"]);
|
|
757
759
|
storage.getLedgerEntries.mockResolvedValue([
|
|
758
760
|
{ id: "entry-1", summary: "Session 1", importance: 3 },
|
|
@@ -763,6 +765,7 @@ describe("ledgerHandlers", () => {
|
|
|
763
765
|
});
|
|
764
766
|
});
|
|
765
767
|
afterEach(async () => {
|
|
768
|
+
delete process.env.PRISM_EXPORT_ROOT;
|
|
766
769
|
await rm(tempDir, { recursive: true, force: true });
|
|
767
770
|
});
|
|
768
771
|
it("exports JSON file for a single project", async () => {
|
|
@@ -832,7 +835,7 @@ describe("ledgerHandlers", () => {
|
|
|
832
835
|
});
|
|
833
836
|
afterEach(async () => {
|
|
834
837
|
// Clean up vault directory if created
|
|
835
|
-
const vaultDir = join(os.homedir(), ".prism-mcp", "media", "
|
|
838
|
+
const vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
|
|
836
839
|
if (fs.existsSync(vaultDir)) {
|
|
837
840
|
await rm(vaultDir, { recursive: true, force: true });
|
|
838
841
|
}
|
|
@@ -840,7 +843,7 @@ describe("ledgerHandlers", () => {
|
|
|
840
843
|
});
|
|
841
844
|
it("saves an image and returns success with image ID", async () => {
|
|
842
845
|
const result = await sessionSaveImageHandler({
|
|
843
|
-
project: "
|
|
846
|
+
project: "__test-img__",
|
|
844
847
|
file_path: testImagePath,
|
|
845
848
|
description: "Dashboard screenshot",
|
|
846
849
|
});
|
|
@@ -851,7 +854,7 @@ describe("ledgerHandlers", () => {
|
|
|
851
854
|
});
|
|
852
855
|
it("updates handoff metadata with visual memory entry", async () => {
|
|
853
856
|
await sessionSaveImageHandler({
|
|
854
|
-
project: "
|
|
857
|
+
project: "__test-img__",
|
|
855
858
|
file_path: testImagePath,
|
|
856
859
|
description: "UI mockup",
|
|
857
860
|
});
|
|
@@ -862,8 +865,8 @@ describe("ledgerHandlers", () => {
|
|
|
862
865
|
});
|
|
863
866
|
it("returns error for non-existent file", async () => {
|
|
864
867
|
const result = await sessionSaveImageHandler({
|
|
865
|
-
project: "
|
|
866
|
-
file_path: "
|
|
868
|
+
project: "__test-img__",
|
|
869
|
+
file_path: join(tempDir, "does-not-exist.png"),
|
|
867
870
|
description: "Missing image",
|
|
868
871
|
});
|
|
869
872
|
expect(result.isError).toBe(true);
|
|
@@ -873,7 +876,7 @@ describe("ledgerHandlers", () => {
|
|
|
873
876
|
const bmpPath = join(tempDir, "test.bmp");
|
|
874
877
|
fs.writeFileSync(bmpPath, Buffer.from([0x42, 0x4d]));
|
|
875
878
|
const result = await sessionSaveImageHandler({
|
|
876
|
-
project: "
|
|
879
|
+
project: "__test-img__",
|
|
877
880
|
file_path: bmpPath,
|
|
878
881
|
description: "BMP image",
|
|
879
882
|
});
|
|
@@ -883,7 +886,7 @@ describe("ledgerHandlers", () => {
|
|
|
883
886
|
it("returns error when no active context exists", async () => {
|
|
884
887
|
storage.loadContext.mockResolvedValue(null);
|
|
885
888
|
const result = await sessionSaveImageHandler({
|
|
886
|
-
project: "
|
|
889
|
+
project: "__test-img__",
|
|
887
890
|
file_path: testImagePath,
|
|
888
891
|
description: "No context image",
|
|
889
892
|
});
|
|
@@ -893,7 +896,7 @@ describe("ledgerHandlers", () => {
|
|
|
893
896
|
// --- Input Validation ---
|
|
894
897
|
it("returns error for invalid args (missing required fields)", async () => {
|
|
895
898
|
const result = await sessionSaveImageHandler({
|
|
896
|
-
project: "
|
|
899
|
+
project: "__test-img__",
|
|
897
900
|
});
|
|
898
901
|
expect(result.isError).toBe(true);
|
|
899
902
|
expect(result.content[0].text).toContain("Invalid arguments");
|
|
@@ -912,7 +915,7 @@ describe("ledgerHandlers", () => {
|
|
|
912
915
|
let vaultImagePath;
|
|
913
916
|
beforeEach(async () => {
|
|
914
917
|
tempDir = await mkdtemp(join(tmpdir(), "prism-view-image-test-"));
|
|
915
|
-
vaultDir = join(os.homedir(), ".prism-mcp", "media", "
|
|
918
|
+
vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
|
|
916
919
|
fs.mkdirSync(vaultDir, { recursive: true });
|
|
917
920
|
// Create a test image in the vault
|
|
918
921
|
vaultImagePath = join(vaultDir, "abc12345.png");
|
|
@@ -941,7 +944,7 @@ describe("ledgerHandlers", () => {
|
|
|
941
944
|
},
|
|
942
945
|
});
|
|
943
946
|
const result = await sessionViewImageHandler({
|
|
944
|
-
project: "
|
|
947
|
+
project: "__test-img__",
|
|
945
948
|
image_id: "abc12345",
|
|
946
949
|
});
|
|
947
950
|
expect(result.isError).toBe(false);
|
|
@@ -964,7 +967,7 @@ describe("ledgerHandlers", () => {
|
|
|
964
967
|
},
|
|
965
968
|
});
|
|
966
969
|
const result = await sessionViewImageHandler({
|
|
967
|
-
project: "
|
|
970
|
+
project: "__test-img__",
|
|
968
971
|
image_id: "nonexistent",
|
|
969
972
|
});
|
|
970
973
|
expect(result.isError).toBe(true);
|
|
@@ -976,7 +979,7 @@ describe("ledgerHandlers", () => {
|
|
|
976
979
|
metadata: {},
|
|
977
980
|
});
|
|
978
981
|
const result = await sessionViewImageHandler({
|
|
979
|
-
project: "
|
|
982
|
+
project: "__test-img__",
|
|
980
983
|
image_id: "any-id",
|
|
981
984
|
});
|
|
982
985
|
expect(result.isError).toBe(true);
|
|
@@ -997,7 +1000,7 @@ describe("ledgerHandlers", () => {
|
|
|
997
1000
|
},
|
|
998
1001
|
});
|
|
999
1002
|
const result = await sessionViewImageHandler({
|
|
1000
|
-
project: "
|
|
1003
|
+
project: "__test-img__",
|
|
1001
1004
|
image_id: "abc12345",
|
|
1002
1005
|
});
|
|
1003
1006
|
expect(result.isError).toBe(true);
|
|
@@ -1018,7 +1021,7 @@ describe("ledgerHandlers", () => {
|
|
|
1018
1021
|
},
|
|
1019
1022
|
});
|
|
1020
1023
|
const result = await sessionViewImageHandler({
|
|
1021
|
-
project: "
|
|
1024
|
+
project: "__test-img__",
|
|
1022
1025
|
image_id: "abc12345",
|
|
1023
1026
|
});
|
|
1024
1027
|
expect(result.isError).toBe(false);
|
|
@@ -1028,7 +1031,7 @@ describe("ledgerHandlers", () => {
|
|
|
1028
1031
|
// --- Input Validation ---
|
|
1029
1032
|
it("returns error for invalid args (missing image_id)", async () => {
|
|
1030
1033
|
const result = await sessionViewImageHandler({
|
|
1031
|
-
project: "
|
|
1034
|
+
project: "__test-img__",
|
|
1032
1035
|
});
|
|
1033
1036
|
expect(result.isError).toBe(true);
|
|
1034
1037
|
});
|
|
@@ -1154,13 +1157,13 @@ describe("ledgerHandlers", () => {
|
|
|
1154
1157
|
});
|
|
1155
1158
|
it("sessionForgetMemoryHandler includes 'tombstoned' in soft-delete response", async () => {
|
|
1156
1159
|
const result = await sessionForgetMemoryHandler({
|
|
1157
|
-
memory_id: "
|
|
1160
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
1158
1161
|
});
|
|
1159
1162
|
expect(result.content[0].text).toContain("tombstoned");
|
|
1160
1163
|
});
|
|
1161
1164
|
it("sessionForgetMemoryHandler mentions hard_delete option in soft-delete response", async () => {
|
|
1162
1165
|
const result = await sessionForgetMemoryHandler({
|
|
1163
|
-
memory_id: "
|
|
1166
|
+
memory_id: "550e8400-e29b-41d4-a716-446655440001",
|
|
1164
1167
|
});
|
|
1165
1168
|
expect(result.content[0].text).toContain("hard_delete: true");
|
|
1166
1169
|
});
|
|
@@ -89,7 +89,7 @@ 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");
|
|
@@ -581,10 +581,17 @@ export async function sessionLoadContextHandler(args) {
|
|
|
581
581
|
if (!isSessionLoadContextArgs(args)) {
|
|
582
582
|
throw new Error("Invalid arguments for session_load_context");
|
|
583
583
|
}
|
|
584
|
-
|
|
584
|
+
// T3 fix: only reset metrics at true session start (totalCalls==0), not on GATE 5 mid-session
|
|
585
|
+
// reloads. Resetting on every call wipes accumulated delegation metrics right before
|
|
586
|
+
// session_save_ledger renders them, making the 📊 block show near-zero after any reload.
|
|
587
|
+
if (getInferenceSnapshot().totalCalls === 0) {
|
|
588
|
+
resetInferenceMetrics();
|
|
589
|
+
}
|
|
585
590
|
const { project, level = "standard", role } = args;
|
|
586
|
-
|
|
587
|
-
|
|
591
|
+
// T6 fix: explicit Number() coercion prevents string "2000" from later concatenating instead of adding
|
|
592
|
+
const _maxTokensArg = Number(args.max_tokens);
|
|
593
|
+
const _maxTokensSetting = parseInt(await getSetting("max_tokens", "0"), 10);
|
|
594
|
+
const maxTokens = (_maxTokensArg > 0 ? _maxTokensArg : undefined) ?? (_maxTokensSetting > 0 ? _maxTokensSetting : undefined);
|
|
588
595
|
const agentName = await getSetting("agent_name", "");
|
|
589
596
|
const validLevels = ["quick", "standard", "deep"];
|
|
590
597
|
if (!validLevels.includes(level)) {
|
|
@@ -600,12 +607,30 @@ export async function sessionLoadContextHandler(args) {
|
|
|
600
607
|
const storage = await getStorage();
|
|
601
608
|
const effectiveRole = role || await getSetting("default_role", "") || undefined;
|
|
602
609
|
const data = await storage.loadContext(project, level, PRISM_USER_ID, effectiveRole); // v3.0: role with dashboard fallback
|
|
610
|
+
// F4 fix: inject protected skills even for fresh projects.
|
|
611
|
+
// Previously this returned before skill injection, leaving new projects with zero
|
|
612
|
+
// behavioral guardrails for the entire session. Now protected skills always load.
|
|
603
613
|
if (!data) {
|
|
614
|
+
let freshSkillBlock = "";
|
|
615
|
+
try {
|
|
616
|
+
const { resolveSkillsForProject: resolveForFresh } = await import("./skillRouting.js");
|
|
617
|
+
const freshResolved = await resolveForFresh(project);
|
|
618
|
+
for (const entry of freshResolved.skills.filter(e => e.protected)) {
|
|
619
|
+
const content = await getSetting(`skill:${entry.name}`, "");
|
|
620
|
+
if (!content?.trim())
|
|
621
|
+
continue;
|
|
622
|
+
freshSkillBlock += `\n\n[📜 SKILL: ${entry.name}]\n${content.trim()}`;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
debugLog(`[session_load_context] Fresh project skill injection failed — continuing without`);
|
|
627
|
+
}
|
|
604
628
|
return {
|
|
605
629
|
content: [{
|
|
606
630
|
type: "text",
|
|
607
631
|
text: `No session context found for project "${project}" at level ${level}.\n` +
|
|
608
|
-
`This project has no previous session history. Starting fresh
|
|
632
|
+
`This project has no previous session history. Starting fresh.` +
|
|
633
|
+
freshSkillBlock,
|
|
609
634
|
}],
|
|
610
635
|
isError: false,
|
|
611
636
|
};
|
|
@@ -855,6 +880,10 @@ export async function sessionLoadContextHandler(args) {
|
|
|
855
880
|
const resolved = await resolveSkillsForProject(project);
|
|
856
881
|
const sortedSkills = resolved.skills;
|
|
857
882
|
const userLocalPolicy = resolved.user_local;
|
|
883
|
+
// F5: surface offline routing to the agent so it knows project/keyword skills may be missing
|
|
884
|
+
if (resolved.isOffline) {
|
|
885
|
+
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.]`;
|
|
886
|
+
}
|
|
858
887
|
let synaluxContent = {};
|
|
859
888
|
if (SYNALUX_CONFIGURED && storage && typeof storage.fetchSkillContent === "function") {
|
|
860
889
|
const missing = sortedSkills.map(s => s.name).filter(n => !loadedSkills.includes(n));
|
|
@@ -994,12 +1023,8 @@ export async function sessionLoadContextHandler(args) {
|
|
|
994
1023
|
behavWarnings.map(w => `- ${w.summary} (importance: ${w.importance})`).join("\n");
|
|
995
1024
|
behavBlock = [...rawBlock].slice(0, 2000).join('');
|
|
996
1025
|
}
|
|
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
1026
|
// ─── 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` +
|
|
1027
|
+
const abaProtocol = `\n\n[🧠 ABA PRECISION PROTOCOL]\n` +
|
|
1003
1028
|
`Rule 1 — Observable Goals: Every task must have a measurable, verifiable outcome. State the specific result.\n` +
|
|
1004
1029
|
`Rule 2 — Precise Execution: One step at a time. Verify each step. If it fails → STOP → fix → verify → then continue.\n` +
|
|
1005
1030
|
`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,15 +1032,57 @@ export async function sessionLoadContextHandler(args) {
|
|
|
1007
1032
|
`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
1033
|
`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
1034
|
`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
|
-
//
|
|
1035
|
+
// T5 structural truncation: assemble sections in priority order so budget truncation
|
|
1036
|
+
// drops the LEAST critical sections first (session history), never skills.
|
|
1037
|
+
// Priority high→low: ABA | skills | behavioral warnings | version | drift | briefing | SDM | history
|
|
1038
|
+
// The header + critical rules are always at the top; history is appended last.
|
|
1039
|
+
const criticalPrefix = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n` +
|
|
1040
|
+
abaProtocol +
|
|
1041
|
+
behavBlock +
|
|
1042
|
+
skillBlock +
|
|
1043
|
+
versionNote +
|
|
1044
|
+
greetingBlock +
|
|
1045
|
+
splitBrainWarning;
|
|
1046
|
+
const lowerPriority = driftReport +
|
|
1047
|
+
briefingBlock +
|
|
1048
|
+
sdmRecallBlock +
|
|
1049
|
+
visualMemoryBlock;
|
|
1050
|
+
const historySection = formattedContext.trim()
|
|
1051
|
+
? `\n\n---\n\n📋 Historical Context:\n${formattedContext.trim()}`
|
|
1052
|
+
: "";
|
|
1053
|
+
// ─── v4.0: Token Budget Truncation (T5 structural version) ──
|
|
1054
|
+
// Drops low-priority sections whole (no mid-slice) before falling back
|
|
1055
|
+
// to trimming the history block. Protected sections (skills, ABA) always survive.
|
|
1056
|
+
// T1: use ~3.5 chars/token (better for emoji+code payload) instead of flat 4.
|
|
1012
1057
|
if (maxTokens && maxTokens > 0) {
|
|
1013
|
-
const maxChars = maxTokens *
|
|
1058
|
+
const maxChars = Math.floor(maxTokens * 3.5);
|
|
1059
|
+
const droppedSections = [];
|
|
1060
|
+
let responseText = criticalPrefix + lowerPriority + historySection;
|
|
1014
1061
|
if (responseText.length > maxChars) {
|
|
1015
|
-
|
|
1016
|
-
|
|
1062
|
+
// Drop low-priority sections first (SDM recall, briefing, drift)
|
|
1063
|
+
responseText = criticalPrefix + historySection;
|
|
1064
|
+
droppedSections.push("briefing", "drift-report", "sdm-recall");
|
|
1065
|
+
debugLog(`[session_load_context] T5: dropped low-priority sections (${droppedSections.join(", ")})`);
|
|
1066
|
+
if (responseText.length > maxChars) {
|
|
1067
|
+
// History too long — trim it, preserving all critical sections
|
|
1068
|
+
const histAllowed = Math.max(0, maxChars - criticalPrefix.length - 200);
|
|
1069
|
+
const trimmedHistory = histAllowed > 0
|
|
1070
|
+
? `\n\n---\n\n📋 Historical Context (trimmed):\n${formattedContext.trim().slice(0, histAllowed)}…`
|
|
1071
|
+
: "";
|
|
1072
|
+
droppedSections.push("partial-history");
|
|
1073
|
+
responseText = criticalPrefix + trimmedHistory;
|
|
1074
|
+
debugLog(`[session_load_context] T5: trimmed history to ${histAllowed} chars`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (droppedSections.length > 0) {
|
|
1078
|
+
responseText += `\n\n[ℹ️ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
|
|
1017
1079
|
}
|
|
1080
|
+
return {
|
|
1081
|
+
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1082
|
+
isError: false,
|
|
1083
|
+
};
|
|
1018
1084
|
}
|
|
1085
|
+
let responseText = criticalPrefix + lowerPriority + historySection;
|
|
1019
1086
|
return {
|
|
1020
1087
|
content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
|
|
1021
1088
|
isError: false,
|
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
import { pickLocalModel, fmtGb, MODEL_TIERS, resolveOllamaName } from "../utils/modelPicker.js";
|
|
23
23
|
import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
|
|
24
24
|
import { getAvailableMemoryBytes } from "../utils/availableMemory.js";
|
|
25
|
-
import { PRISM_SYNALUX_BASE_URL, PRISM_LOCAL_LLM_URL, } from "../config.js";
|
|
25
|
+
import { PRISM_SYNALUX_BASE_URL, PRISM_LOCAL_LLM_URL, SYNALUX_CONFIGURED, } from "../config.js";
|
|
26
26
|
import { debugLog } from "../utils/logger.js";
|
|
27
27
|
import { getEntitlements, clampCeiling } from "../utils/entitlements.js";
|
|
28
28
|
import { ddLog } from "../utils/ddLogger.js";
|
|
29
29
|
import { stripThink } from "../utils/thinkStrip.js";
|
|
30
30
|
import { passesQualityGate } from "../utils/qualityGate.js";
|
|
31
31
|
import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
|
|
32
|
-
import {
|
|
32
|
+
import { callLayer1 as defaultCallLayer1 } from "../utils/layer1.js";
|
|
33
|
+
import { recordInference, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
|
|
33
34
|
// ─── Tool Definition ────────────────────────────────────────────
|
|
34
35
|
export const PRISM_INFER_TOOL = {
|
|
35
36
|
name: "prism_infer",
|
|
@@ -299,6 +300,48 @@ async function callSynaluxInference(prompt, maxTokens, timeoutMs) {
|
|
|
299
300
|
return { ok: false, reason: name === "TimeoutError" || name === "AbortError" ? "synalux_timeout" : "synalux_network" };
|
|
300
301
|
}
|
|
301
302
|
}
|
|
303
|
+
// ─── Portal verifier (thin-client HTTP call) ──────────────────
|
|
304
|
+
async function callSynaluxVerifier(opts) {
|
|
305
|
+
if (!PRISM_SYNALUX_BASE_URL)
|
|
306
|
+
throw new Error("no_synalux_base_url");
|
|
307
|
+
const jwt = await getSynaluxJwt();
|
|
308
|
+
if (!jwt)
|
|
309
|
+
throw new Error("jwt_exchange_failed");
|
|
310
|
+
const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/verify-grounding`;
|
|
311
|
+
const res = await fetch(url, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: {
|
|
314
|
+
"Authorization": `Bearer ${jwt}`,
|
|
315
|
+
"Content-Type": "application/json",
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
draft: opts.draft,
|
|
319
|
+
evidence: opts.evidence,
|
|
320
|
+
verifierModel: opts.verifierModel,
|
|
321
|
+
// Give portal 500ms headroom before our own AbortSignal fires.
|
|
322
|
+
timeoutMs: Math.max(500, (opts.timeoutMs ?? 5_000) - 500),
|
|
323
|
+
}),
|
|
324
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),
|
|
325
|
+
redirect: "error",
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok)
|
|
328
|
+
throw new Error(`synalux_verifier_http_${res.status}`);
|
|
329
|
+
return res.json();
|
|
330
|
+
}
|
|
331
|
+
// In-process mutex that serialises eviction so concurrent requests don't evict
|
|
332
|
+
// a model that another in-flight inference is actively using (F3 fix).
|
|
333
|
+
const _evictionMutex = (() => {
|
|
334
|
+
let _lock = Promise.resolve();
|
|
335
|
+
return {
|
|
336
|
+
acquire() {
|
|
337
|
+
let release;
|
|
338
|
+
const next = new Promise(resolve => { release = resolve; });
|
|
339
|
+
const chain = _lock.then(() => release);
|
|
340
|
+
_lock = _lock.then(() => next);
|
|
341
|
+
return chain;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
})();
|
|
302
345
|
export async function runInfer(args, deps) {
|
|
303
346
|
const t0 = Date.now();
|
|
304
347
|
const temperature = args.temperature ?? 0;
|
|
@@ -366,14 +409,105 @@ export async function runInfer(args, deps) {
|
|
|
366
409
|
if (installed === null) {
|
|
367
410
|
attempts.push({ tier: "ollama_probe", reason: "unreachable" });
|
|
368
411
|
}
|
|
412
|
+
// ── §E Layer 1 semantic pre-classifier ──────────────────────────────────
|
|
413
|
+
// Catches adversarial paraphrases the keyword stub misses.
|
|
414
|
+
// Runs only when cloud escalation is possible — without cloud, there is
|
|
415
|
+
// nowhere to route a RESERVED verdict.
|
|
416
|
+
// Recursion guard: skip when this call IS the Layer 1 classification
|
|
417
|
+
// (mode="route" + max_tokens<=16 is the Layer 1 call signature).
|
|
418
|
+
const layer1RecursionGuard = mode === "route" && maxTokens <= 16;
|
|
419
|
+
if (allowCloud && !layer1RecursionGuard) {
|
|
420
|
+
const l1fn = deps.callLayer1 ?? defaultCallLayer1;
|
|
421
|
+
const l1Model = resolveOllamaName("prism-coder:4b", installed ?? new Set());
|
|
422
|
+
const l1 = await l1fn(args.prompt, deps.ollamaUrl, l1Model);
|
|
423
|
+
if (l1 !== "OBVIOUS_NOT_RESERVED") {
|
|
424
|
+
debugLog(`[prism_infer] Layer 1 verdict=${l1} — escalating to cloud`);
|
|
425
|
+
attempts.push({ tier: "layer1", reason: `layer1_${l1.toLowerCase()}` });
|
|
426
|
+
const cloudTimeout = args.timeout_ms ?? 90_000;
|
|
427
|
+
const cloud = await deps.callCloud(args.prompt, maxTokens, cloudTimeout);
|
|
428
|
+
if (cloud.ok && cloud.output) {
|
|
429
|
+
return await applyVerification(cloud.output, gatedArgs, deps, {
|
|
430
|
+
backend: cloud.backend ?? "synalux",
|
|
431
|
+
model_picked: null,
|
|
432
|
+
ram_free_mb: ramFreeMb,
|
|
433
|
+
latency_ms: Date.now() - t0,
|
|
434
|
+
used_cloud: true,
|
|
435
|
+
attempts,
|
|
436
|
+
plan: ent.plan,
|
|
437
|
+
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
|
|
441
|
+
// Layer 1 flagged RESERVED but cloud unavailable — fail closed, never fall through to local.
|
|
442
|
+
throw new Error(`prism_infer: Layer 1 verdict=${l1} but cloud unavailable. attempts=${JSON.stringify(attempts)}`);
|
|
443
|
+
}
|
|
444
|
+
debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
|
|
445
|
+
}
|
|
446
|
+
// ── end Layer 1 ─────────────────────────────────────────────────────────
|
|
369
447
|
// Walk the tier table top → bottom, capped by model_ceiling. Each tier
|
|
370
448
|
// logs its skip reason ("not_pulled" / "ram_insufficient" / fail reason)
|
|
371
449
|
// so the caller can see exactly why each tier was bypassed.
|
|
372
450
|
let localDraft = null;
|
|
373
451
|
if (installed) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
452
|
+
// F4 fix: guard ceiling-not-found — Math.max(0,-1) silently targets tier 0 (27b).
|
|
453
|
+
// Instead of defaulting to the largest tier, treat not-found as "no ceiling" (start=0).
|
|
454
|
+
const ceilIdx = effectiveCeiling
|
|
455
|
+
? MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`))
|
|
456
|
+
: -1;
|
|
457
|
+
const ceilStart = ceilIdx >= 0 ? ceilIdx : 0;
|
|
458
|
+
// Auto-evict: if the ceiling tier is installed but not warm and prism's
|
|
459
|
+
// own smaller tier models are warm, unload them to make room.
|
|
460
|
+
// Operates only on prism tier models — never evicts arbitrary Ollama models
|
|
461
|
+
// the caller doesn't own (F1). Uses an in-process mutex to prevent a
|
|
462
|
+
// concurrent request from evicting a model mid-inference (F3).
|
|
463
|
+
let freeAfterEvict = freeBytes;
|
|
464
|
+
if (loaded && loaded.size > 0) {
|
|
465
|
+
const ceilTier = MODEL_TIERS[ceilIdx >= 0 ? ceilIdx : 0];
|
|
466
|
+
const ceilName = ceilTier ? resolveOllamaName(ceilTier.tag, installed) : null;
|
|
467
|
+
const ceilInstalled = ceilName ? installed.has(ceilName) : false;
|
|
468
|
+
const ceilWarm = ceilName ? loaded.has(ceilName) : false;
|
|
469
|
+
if (ceilInstalled && !ceilWarm) {
|
|
470
|
+
// F1 fix: only count and evict prism tier models — not arbitrary warm models.
|
|
471
|
+
const tierModelsToEvict = MODEL_TIERS
|
|
472
|
+
.map(t => resolveOllamaName(t.tag, installed))
|
|
473
|
+
.filter(name => loaded.has(name));
|
|
474
|
+
const tierWarmBytes = tierModelsToEvict.reduce((sum, name) => {
|
|
475
|
+
const t = MODEL_TIERS.find(t => resolveOllamaName(t.tag, installed) === name);
|
|
476
|
+
return sum + (t ? t.weightsGb * 1024 ** 3 : 0);
|
|
477
|
+
}, 0);
|
|
478
|
+
if (freeBytes + tierWarmBytes >= ceilTier.minFreeGb * 1024 ** 3) {
|
|
479
|
+
// F3 fix: hold eviction mutex so no concurrent request evicts a model
|
|
480
|
+
// that another in-flight inference is actively using.
|
|
481
|
+
const released = await _evictionMutex.acquire();
|
|
482
|
+
try {
|
|
483
|
+
// F2 fix: await each evict call; log failures; don't proceed blind.
|
|
484
|
+
const evictResults = await Promise.allSettled(tierModelsToEvict.map(m => fetch(`${deps.ollamaUrl}/api/generate`, {
|
|
485
|
+
method: "POST",
|
|
486
|
+
body: JSON.stringify({ model: m, keep_alive: 0 }),
|
|
487
|
+
signal: AbortSignal.timeout(3_000),
|
|
488
|
+
})));
|
|
489
|
+
const failed = evictResults.filter(r => r.status === "rejected").length;
|
|
490
|
+
if (failed > 0) {
|
|
491
|
+
debugLog(`[prism_infer] evict: ${failed}/${tierModelsToEvict.length} unload requests failed`);
|
|
492
|
+
}
|
|
493
|
+
// Settle: give Ollama time to release buffers before re-reading RAM.
|
|
494
|
+
await new Promise(r => setTimeout(r, 800));
|
|
495
|
+
freeAfterEvict = deps.freemem();
|
|
496
|
+
debugLog(`[prism_infer] auto-evicted ${tierModelsToEvict.join(", ")} ` +
|
|
497
|
+
`(${fmtGb(tierWarmBytes)}) → freeAfterEvict=${fmtGb(freeAfterEvict)}`);
|
|
498
|
+
// F2 fix: if still insufficient after eviction, log and fall through
|
|
499
|
+
// cleanly — the tier loop will emit ram_insufficient rather than
|
|
500
|
+
// proceeding on a stale freeBytes value.
|
|
501
|
+
if (freeAfterEvict < ceilTier.minFreeGb * 1024 ** 3) {
|
|
502
|
+
debugLog(`[prism_infer] evict completed but RAM still insufficient for ${ceilTier.tag}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
released();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
377
511
|
let anyViable = false;
|
|
378
512
|
for (let i = ceilStart; i < MODEL_TIERS.length; i++) {
|
|
379
513
|
const tier = MODEL_TIERS[i];
|
|
@@ -389,7 +523,7 @@ export async function runInfer(args, deps) {
|
|
|
389
523
|
// RAM gate — but skip the check if the tier is already warm in
|
|
390
524
|
// Ollama. Reused models don't reallocate weight buffers.
|
|
391
525
|
const isWarm = loaded.has(ollamaName);
|
|
392
|
-
if (!isWarm &&
|
|
526
|
+
if (!isWarm && freeAfterEvict < tier.minFreeGb * (1024 ** 3)) {
|
|
393
527
|
attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
|
|
394
528
|
continue;
|
|
395
529
|
}
|
|
@@ -400,20 +534,18 @@ export async function runInfer(args, deps) {
|
|
|
400
534
|
if (result.ok) {
|
|
401
535
|
const { stripped, thinkOnly } = stripThink(result.text);
|
|
402
536
|
const output = stripped;
|
|
403
|
-
// Quality gate
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
|
|
411
|
-
}
|
|
412
|
-
break;
|
|
413
|
-
}
|
|
414
|
-
if (!gate.pass) {
|
|
415
|
-
debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
|
|
537
|
+
// Quality gate — all modes. Route uses mode-aware empty floor (length===0).
|
|
538
|
+
const gate = passesQualityGate(output, thinkOnly, result.doneReason, mode);
|
|
539
|
+
if (!gate.pass && allowCloud) {
|
|
540
|
+
debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
|
|
541
|
+
attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
|
|
542
|
+
if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
|
|
543
|
+
localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
|
|
416
544
|
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
if (!gate.pass) {
|
|
548
|
+
debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
|
|
417
549
|
}
|
|
418
550
|
return await applyVerification(output, gatedArgs, deps, {
|
|
419
551
|
backend: `ollama-${tier.tag.replace("prism-coder:", "")}`,
|
|
@@ -448,7 +580,9 @@ export async function runInfer(args, deps) {
|
|
|
448
580
|
used_cloud: true,
|
|
449
581
|
attempts,
|
|
450
582
|
plan: ent.plan,
|
|
451
|
-
|
|
583
|
+
// T4: omit prompt_tokens — cloud doesn't return Ollama actual eval count.
|
|
584
|
+
// recordInference receives prompt_text and computes submittedEst via
|
|
585
|
+
// estimateTokens(), keeping promptTokensEvaluated=0 (correct for cloud).
|
|
452
586
|
completion_tokens: Math.ceil(cloud.output.length / 4),
|
|
453
587
|
});
|
|
454
588
|
}
|
|
@@ -524,10 +658,13 @@ export async function prismInferHandler(args) {
|
|
|
524
658
|
callLocal: callOllamaGenerate,
|
|
525
659
|
callCloud: callSynaluxInference,
|
|
526
660
|
ollamaUrl: PRISM_LOCAL_LLM_URL,
|
|
661
|
+
callVerifier: SYNALUX_CONFIGURED ? callSynaluxVerifier : undefined,
|
|
527
662
|
});
|
|
528
663
|
debugLog(`[prism_infer] backend=${result.backend} model=${result.model_picked} latency=${result.latency_ms}ms free=${result.ram_free_mb}MB`);
|
|
529
664
|
// Local accumulator — sole source of the user-facing metrics block.
|
|
530
|
-
recordInference
|
|
665
|
+
// T4: pass prompt_text so recordInference computes submittedEst via
|
|
666
|
+
// estimateTokens() — critical for cloud path where prompt_tokens is unset.
|
|
667
|
+
recordInference({ ...result, prompt_text: args.prompt });
|
|
531
668
|
// Best-effort portal forwarding (independent analytics stream).
|
|
532
669
|
// safety_gate excluded — logging crisis filter triggers is a HIPAA concern.
|
|
533
670
|
if (result.backend !== "safety_gate") {
|
|
@@ -543,7 +680,7 @@ export async function prismInferHandler(args) {
|
|
|
543
680
|
const tokenStr = result.prompt_tokens != null || result.completion_tokens != null
|
|
544
681
|
? ` tokens=${result.prompt_tokens ?? "?"}in/${result.completion_tokens ?? "?"}out`
|
|
545
682
|
: "";
|
|
546
|
-
const
|
|
683
|
+
const headerBase = `[prism_infer] backend=${result.backend}` +
|
|
547
684
|
` model=${result.model_picked ?? "n/a"}` +
|
|
548
685
|
` plan=${result.plan ?? "unknown"}` +
|
|
549
686
|
` free_ram=${result.ram_free_mb}MB` +
|
|
@@ -553,6 +690,11 @@ export async function prismInferHandler(args) {
|
|
|
553
690
|
(result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
|
|
554
691
|
(result.verification ? ` verify=${result.verification.action}` : "") +
|
|
555
692
|
(result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
|
|
693
|
+
// Append periodic session-level stats to the header line.
|
|
694
|
+
// compact=true is threshold-gated (PRISM_METRICS_EVERY, default every 5 calls)
|
|
695
|
+
// so it doesn't appear on every response — only as a rolling summary.
|
|
696
|
+
const metricsLine = formatInferenceMetrics(true);
|
|
697
|
+
const header = metricsLine ? `${headerBase}\n${metricsLine}` : headerBase;
|
|
556
698
|
return {
|
|
557
699
|
content: [
|
|
558
700
|
{ type: "text", text: header },
|