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.
@@ -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("truncated to fit token budget");
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: "uuid-123",
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("uuid-123");
685
- expect(storage.softDeleteLedger).toHaveBeenCalledWith("uuid-123", "test-user-id", undefined);
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: "uuid-456",
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("uuid-456", "test-user-id", "GDPR Article 17 request");
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: "uuid-789",
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("uuid-789", "test-user-id");
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: "uuid-aaa",
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: "nonexistent-uuid",
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: "uuid-fail",
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", "test-project");
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: "test-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: "test-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: "test-project",
866
- file_path: "/nonexistent/path/image.png",
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: "test-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: "test-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: "test-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", "test-project");
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: "test-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: "test-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: "test-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: "test-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: "test-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: "test-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: "uuid-test",
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: "uuid-test",
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
- resetInferenceMetrics();
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
- const maxTokens = args.max_tokens
587
- || parseInt(await getSetting("max_tokens", "0"), 10) || undefined; // v4.0: arg > dashboard setting > none
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
- // Injected into EVERY session load so the agent always operates
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
- // ─── v4.0: Token Budget Truncation ─────────────────────────
1011
- // 1 token 4 chars heuristic. Truncate if response exceeds budget.
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 * 4;
1058
+ const maxChars = Math.floor(maxTokens * 3.5);
1059
+ const droppedSections = [];
1060
+ let responseText = criticalPrefix + lowerPriority + historySection;
1014
1061
  if (responseText.length > maxChars) {
1015
- responseText = responseText.slice(0, maxChars) + "\n\n[… truncated to fit token budget]";
1016
- debugLog(`[session_load_context] Truncated response to ${maxTokens} tokens (${maxChars} chars)`);
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 { recordInference } from "../utils/inferenceMetrics.js";
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
- const ceilStart = effectiveCeiling
375
- ? Math.max(0, MODEL_TIERS.findIndex(t => t.tag.endsWith(`:${effectiveCeiling}`)))
376
- : 0;
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 && freeBytes < tier.minFreeGb * (1024 ** 3)) {
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 for chat/code modes
404
- if (mode !== "route") {
405
- const gate = passesQualityGate(output, thinkOnly, result.doneReason);
406
- if (!gate.pass && allowCloud) {
407
- debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
408
- attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
409
- if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
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
- prompt_tokens: Math.ceil(args.prompt.length / 4),
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(result);
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 header = `[prism_infer] backend=${result.backend}` +
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 },