prism-mcp-server 19.2.7 → 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
  });
@@ -12,6 +12,7 @@
12
12
  * The handler is storage-agnostic — works with SQLite (local) or Supabase (remote).
13
13
  */
14
14
  import { readFileSync, existsSync } from "fs";
15
+ import { resolve as resolvePath } from "path";
15
16
  import { basename } from "path";
16
17
  import { PRISM_USER_ID } from "../config.js";
17
18
  import { getStorage } from "../storage/index.js";
@@ -103,7 +104,7 @@ export async function ingestKnowledge(args) {
103
104
  const { project, source_label, chunk_size = 4000, } = args;
104
105
  let content = args.content || "";
105
106
  if (args.file_path) {
106
- const resolved = require("path").resolve(args.file_path);
107
+ const resolved = resolvePath(args.file_path);
107
108
  const blocked = ["/etc", "/var", "/usr", "/sys", "/proc", "/dev", "/root",
108
109
  "/.ssh", "/.env", "/.git/config", "/private/etc"].some(p => resolved.startsWith(p) || resolved.includes("/."));
109
110
  if (blocked) {
@@ -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,