prism-mcp-server 19.2.8 → 19.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,6 +54,7 @@ vi.mock("../../../src/config.js", () => ({
54
54
  PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP: 25,
55
55
  PRISM_ACTR_ENABLED: false,
56
56
  PRISM_ACTR_ACCESS_LOG_RETENTION_DAYS: 90,
57
+ SYNALUX_CONFIGURED: false,
57
58
  }));
58
59
  vi.mock("../../../src/utils/logger.js", () => ({
59
60
  debugLog: vi.fn(),
@@ -120,6 +121,19 @@ vi.mock("../../../src/utils/cognitiveMemory.js", () => ({
120
121
  computeEffectiveImportance: vi.fn((imp) => imp),
121
122
  recordMemoryAccess: vi.fn(),
122
123
  }));
124
+ // Session context gate — default to "pass" (null) so existing tests are unaffected.
125
+ // Gate-blocking behaviour is tested explicitly in the "context gate" sections below.
126
+ vi.mock("../../../src/session/sessionContext.js", () => ({
127
+ markContextLoaded: vi.fn(),
128
+ requireContextLoaded: vi.fn(() => null),
129
+ noteInferenceForSession: vi.fn(),
130
+ getSessionState: vi.fn(() => null),
131
+ }));
132
+ // Boundaries — return minimal stubs so load-context tests don't depend on exact text.
133
+ vi.mock("../../../src/boundaries/boundaries.js", () => ({
134
+ BOUNDARIES_VERSION: "test",
135
+ BOUNDARIES_TEXT: "# BOUNDARIES STUB",
136
+ }));
123
137
  vi.mock("../../../src/tools/commonHelpers.js", () => ({
124
138
  redactSettings: vi.fn((s) => s),
125
139
  toMarkdown: vi.fn(() => "# Markdown Export"),
@@ -132,6 +146,7 @@ vi.mock("../../../src/utils/vaultExporter.js", () => ({
132
146
  // ======================================================================
133
147
  import { getStorage } from "../../../src/storage/index.js";
134
148
  import { getSetting, getAllSettings } from "../../../src/storage/configStorage.js";
149
+ import { requireContextLoaded, markContextLoaded } from "../../../src/session/sessionContext.js";
135
150
  import { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler, memoryHistoryHandler, sessionSaveImageHandler, sessionViewImageHandler, sanitizeMemoryInput, } from "../../../src/tools/ledgerHandlers.js";
136
151
  const mockGetStorage = vi.mocked(getStorage);
137
152
  const mockGetSetting = vi.mocked(getSetting);
@@ -328,6 +343,36 @@ describe("ledgerHandlers", () => {
328
343
  storage.saveLedger.mockRejectedValue(new Error("DB write failed"));
329
344
  await expect(sessionSaveLedgerHandler(validArgs)).rejects.toThrow("DB write failed");
330
345
  });
346
+ // --- Context gate ---
347
+ it("blocks save and returns structured error when context not loaded", async () => {
348
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
349
+ blocked: true,
350
+ error: "context_not_loaded: call session_load_context first.",
351
+ });
352
+ const result = await sessionSaveLedgerHandler(validArgs);
353
+ expect(result.isError).toBe(true);
354
+ expect(result.content[0].text).toContain("context_not_loaded");
355
+ expect(storage.saveLedger).not.toHaveBeenCalled();
356
+ });
357
+ it("passes through to storage when context is loaded (gate returns null)", async () => {
358
+ vi.mocked(requireContextLoaded).mockReturnValueOnce(null);
359
+ const result = await sessionSaveLedgerHandler(validArgs);
360
+ expect(result.isError).toBe(false);
361
+ expect(storage.saveLedger).toHaveBeenCalledTimes(1);
362
+ });
363
+ it("proceeds and prepends warning when gate returns { blocked: false, warning } (version drift)", async () => {
364
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
365
+ blocked: false,
366
+ warning: "[advisory] Operating boundaries updated. Call session_load_context again.",
367
+ });
368
+ const result = await sessionSaveLedgerHandler(validArgs);
369
+ // Must NOT block — write proceeds
370
+ expect(result.isError).toBe(false);
371
+ expect(storage.saveLedger).toHaveBeenCalledTimes(1);
372
+ // Warning is prepended to the success text
373
+ expect(result.content[0].text).toContain("[advisory] Operating boundaries updated");
374
+ expect(result.content[0].text).toContain("✅ Session ledger saved");
375
+ });
331
376
  });
332
377
  // ====================================================================
333
378
  // 3. sessionLoadContextHandler
@@ -427,7 +472,7 @@ describe("ledgerHandlers", () => {
427
472
  });
428
473
  const text = result.content[0].text;
429
474
  // With 100 tokens * 4 chars = 400 char budget, the 5000-char summary gets truncated
430
- expect(text).toContain("truncated to fit token budget");
475
+ expect(text).toContain("Sections omitted to fit token budget");
431
476
  });
432
477
  it("includes recent sessions in formatted output", async () => {
433
478
  storage.loadContext.mockResolvedValue({
@@ -472,6 +517,24 @@ describe("ledgerHandlers", () => {
472
517
  storage.loadContext.mockRejectedValue(new Error("Connection timeout"));
473
518
  await expect(sessionLoadContextHandler(validArgs)).rejects.toThrow("Connection timeout");
474
519
  });
520
+ // --- conversation_id / markContextLoaded ---
521
+ it("calls markContextLoaded when conversation_id is provided", async () => {
522
+ storage.loadContext.mockResolvedValue(null);
523
+ await sessionLoadContextHandler({ project: "test-project", conversation_id: "conv-xyz" });
524
+ expect(vi.mocked(markContextLoaded)).toHaveBeenCalledWith("conv-xyz", "test-project", "test");
525
+ });
526
+ it("does not call markContextLoaded when conversation_id is absent", async () => {
527
+ storage.loadContext.mockResolvedValue(null);
528
+ await sessionLoadContextHandler({ project: "test-project" });
529
+ expect(vi.mocked(markContextLoaded)).not.toHaveBeenCalled();
530
+ });
531
+ it("prepends BOUNDARIES header to every response", async () => {
532
+ storage.loadContext.mockResolvedValue(null);
533
+ const result = await sessionLoadContextHandler(validArgs);
534
+ const text = result.content[0].text;
535
+ expect(text).toContain("OPERATING BOUNDARIES");
536
+ expect(text).toContain("BOUNDARIES STUB");
537
+ });
475
538
  });
476
539
  // ====================================================================
477
540
  // 4. sessionSaveHandoffHandler
@@ -605,6 +668,38 @@ describe("ledgerHandlers", () => {
605
668
  storage.saveHandoff.mockRejectedValue(new Error("Write conflict"));
606
669
  await expect(sessionSaveHandoffHandler(validArgs)).rejects.toThrow("Write conflict");
607
670
  });
671
+ // --- Context gate ---
672
+ it("blocks handoff save and returns structured error when context not loaded", async () => {
673
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
674
+ blocked: true,
675
+ error: "context_not_loaded: call session_load_context first.",
676
+ });
677
+ const result = await sessionSaveHandoffHandler(validArgs);
678
+ expect(result.isError).toBe(true);
679
+ expect(result.content[0].text).toContain("context_not_loaded");
680
+ expect(storage.saveHandoff).not.toHaveBeenCalled();
681
+ });
682
+ it("passes through to storage when context is loaded (gate returns null)", async () => {
683
+ storage.saveHandoff.mockResolvedValue({ status: "created", version: 1 });
684
+ vi.mocked(requireContextLoaded).mockReturnValueOnce(null);
685
+ const result = await sessionSaveHandoffHandler(validArgs);
686
+ expect(result.isError).toBe(false);
687
+ expect(storage.saveHandoff).toHaveBeenCalledTimes(1);
688
+ });
689
+ it("proceeds and prepends warning when gate returns { blocked: false, warning } (version drift)", async () => {
690
+ storage.saveHandoff.mockResolvedValue({ status: "created", version: 1 });
691
+ vi.mocked(requireContextLoaded).mockReturnValueOnce({
692
+ blocked: false,
693
+ warning: "[advisory] Operating boundaries updated. Call session_load_context again.",
694
+ });
695
+ const result = await sessionSaveHandoffHandler(validArgs);
696
+ // Must NOT block — write proceeds
697
+ expect(result.isError).toBe(false);
698
+ expect(storage.saveHandoff).toHaveBeenCalledTimes(1);
699
+ // Warning is prepended to the success text
700
+ expect(result.content[0].text).toContain("[advisory] Operating boundaries updated");
701
+ expect(result.content[0].text).toContain("✅ Handoff");
702
+ });
608
703
  });
609
704
  // ====================================================================
610
705
  // 5. memoryHistoryHandler
@@ -677,36 +772,36 @@ describe("ledgerHandlers", () => {
677
772
  describe("sessionForgetMemoryHandler", () => {
678
773
  it("soft-deletes a memory entry by default", async () => {
679
774
  const result = await sessionForgetMemoryHandler({
680
- memory_id: "uuid-123",
775
+ memory_id: "550e8400-e29b-41d4-a716-446655440123",
681
776
  });
682
777
  expect(result.isError).toBe(false);
683
778
  expect(result.content[0].text).toContain("Soft Deleted");
684
- expect(result.content[0].text).toContain("uuid-123");
685
- expect(storage.softDeleteLedger).toHaveBeenCalledWith("uuid-123", "test-user-id", undefined);
779
+ expect(result.content[0].text).toContain("550e8400-e29b-41d4-a716-446655440123");
780
+ expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440123", "test-user-id", undefined);
686
781
  });
687
782
  it("soft-deletes with reason for audit trail", async () => {
688
783
  const result = await sessionForgetMemoryHandler({
689
- memory_id: "uuid-456",
784
+ memory_id: "550e8400-e29b-41d4-a716-446655440456",
690
785
  reason: "GDPR Article 17 request",
691
786
  });
692
787
  expect(result.isError).toBe(false);
693
788
  expect(result.content[0].text).toContain("Reason");
694
789
  expect(result.content[0].text).toContain("GDPR Article 17 request");
695
- expect(storage.softDeleteLedger).toHaveBeenCalledWith("uuid-456", "test-user-id", "GDPR Article 17 request");
790
+ expect(storage.softDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440456", "test-user-id", "GDPR Article 17 request");
696
791
  });
697
792
  it("hard-deletes when hard_delete is true", async () => {
698
793
  const result = await sessionForgetMemoryHandler({
699
- memory_id: "uuid-789",
794
+ memory_id: "550e8400-e29b-41d4-a716-446655440789",
700
795
  hard_delete: true,
701
796
  });
702
797
  expect(result.isError).toBe(false);
703
798
  expect(result.content[0].text).toContain("Hard Deleted");
704
799
  expect(result.content[0].text).toContain("permanently removed");
705
- expect(storage.hardDeleteLedger).toHaveBeenCalledWith("uuid-789", "test-user-id");
800
+ expect(storage.hardDeleteLedger).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440789", "test-user-id");
706
801
  });
707
802
  it("does not call hardDeleteLedger when hard_delete is false", async () => {
708
803
  await sessionForgetMemoryHandler({
709
- memory_id: "uuid-aaa",
804
+ memory_id: "550e8400-e29b-41d4-a716-446655440aaa",
710
805
  hard_delete: false,
711
806
  });
712
807
  expect(storage.hardDeleteLedger).not.toHaveBeenCalled();
@@ -731,7 +826,7 @@ describe("ledgerHandlers", () => {
731
826
  it("catches storage errors and returns isError (never throws)", async () => {
732
827
  storage.softDeleteLedger.mockRejectedValue(new Error("Entry not found"));
733
828
  const result = await sessionForgetMemoryHandler({
734
- memory_id: "nonexistent-uuid",
829
+ memory_id: "550e8400-e29b-41d4-a716-446655440f99",
735
830
  });
736
831
  expect(result.isError).toBe(true);
737
832
  expect(result.content[0].text).toContain("Entry not found");
@@ -739,7 +834,7 @@ describe("ledgerHandlers", () => {
739
834
  it("catches hard_delete storage errors gracefully", async () => {
740
835
  storage.hardDeleteLedger.mockRejectedValue(new Error("FK constraint"));
741
836
  const result = await sessionForgetMemoryHandler({
742
- memory_id: "uuid-fail",
837
+ memory_id: "550e8400-e29b-41d4-a716-446655440f11",
743
838
  hard_delete: true,
744
839
  });
745
840
  expect(result.isError).toBe(true);
@@ -753,6 +848,7 @@ describe("ledgerHandlers", () => {
753
848
  let tempDir;
754
849
  beforeEach(async () => {
755
850
  tempDir = await mkdtemp(join(tmpdir(), "prism-handler-export-"));
851
+ process.env.PRISM_EXPORT_ROOT = tempDir;
756
852
  storage.listProjects.mockResolvedValue(["test-project"]);
757
853
  storage.getLedgerEntries.mockResolvedValue([
758
854
  { id: "entry-1", summary: "Session 1", importance: 3 },
@@ -763,6 +859,7 @@ describe("ledgerHandlers", () => {
763
859
  });
764
860
  });
765
861
  afterEach(async () => {
862
+ delete process.env.PRISM_EXPORT_ROOT;
766
863
  await rm(tempDir, { recursive: true, force: true });
767
864
  });
768
865
  it("exports JSON file for a single project", async () => {
@@ -832,7 +929,7 @@ describe("ledgerHandlers", () => {
832
929
  });
833
930
  afterEach(async () => {
834
931
  // Clean up vault directory if created
835
- const vaultDir = join(os.homedir(), ".prism-mcp", "media", "test-project");
932
+ const vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
836
933
  if (fs.existsSync(vaultDir)) {
837
934
  await rm(vaultDir, { recursive: true, force: true });
838
935
  }
@@ -840,7 +937,7 @@ describe("ledgerHandlers", () => {
840
937
  });
841
938
  it("saves an image and returns success with image ID", async () => {
842
939
  const result = await sessionSaveImageHandler({
843
- project: "test-project",
940
+ project: "__test-img__",
844
941
  file_path: testImagePath,
845
942
  description: "Dashboard screenshot",
846
943
  });
@@ -851,7 +948,7 @@ describe("ledgerHandlers", () => {
851
948
  });
852
949
  it("updates handoff metadata with visual memory entry", async () => {
853
950
  await sessionSaveImageHandler({
854
- project: "test-project",
951
+ project: "__test-img__",
855
952
  file_path: testImagePath,
856
953
  description: "UI mockup",
857
954
  });
@@ -862,8 +959,8 @@ describe("ledgerHandlers", () => {
862
959
  });
863
960
  it("returns error for non-existent file", async () => {
864
961
  const result = await sessionSaveImageHandler({
865
- project: "test-project",
866
- file_path: "/nonexistent/path/image.png",
962
+ project: "__test-img__",
963
+ file_path: join(tempDir, "does-not-exist.png"),
867
964
  description: "Missing image",
868
965
  });
869
966
  expect(result.isError).toBe(true);
@@ -873,7 +970,7 @@ describe("ledgerHandlers", () => {
873
970
  const bmpPath = join(tempDir, "test.bmp");
874
971
  fs.writeFileSync(bmpPath, Buffer.from([0x42, 0x4d]));
875
972
  const result = await sessionSaveImageHandler({
876
- project: "test-project",
973
+ project: "__test-img__",
877
974
  file_path: bmpPath,
878
975
  description: "BMP image",
879
976
  });
@@ -883,7 +980,7 @@ describe("ledgerHandlers", () => {
883
980
  it("returns error when no active context exists", async () => {
884
981
  storage.loadContext.mockResolvedValue(null);
885
982
  const result = await sessionSaveImageHandler({
886
- project: "test-project",
983
+ project: "__test-img__",
887
984
  file_path: testImagePath,
888
985
  description: "No context image",
889
986
  });
@@ -893,7 +990,7 @@ describe("ledgerHandlers", () => {
893
990
  // --- Input Validation ---
894
991
  it("returns error for invalid args (missing required fields)", async () => {
895
992
  const result = await sessionSaveImageHandler({
896
- project: "test-project",
993
+ project: "__test-img__",
897
994
  });
898
995
  expect(result.isError).toBe(true);
899
996
  expect(result.content[0].text).toContain("Invalid arguments");
@@ -912,7 +1009,7 @@ describe("ledgerHandlers", () => {
912
1009
  let vaultImagePath;
913
1010
  beforeEach(async () => {
914
1011
  tempDir = await mkdtemp(join(tmpdir(), "prism-view-image-test-"));
915
- vaultDir = join(os.homedir(), ".prism-mcp", "media", "test-project");
1012
+ vaultDir = join(os.homedir(), ".prism-mcp", "media", "__test-img__");
916
1013
  fs.mkdirSync(vaultDir, { recursive: true });
917
1014
  // Create a test image in the vault
918
1015
  vaultImagePath = join(vaultDir, "abc12345.png");
@@ -941,7 +1038,7 @@ describe("ledgerHandlers", () => {
941
1038
  },
942
1039
  });
943
1040
  const result = await sessionViewImageHandler({
944
- project: "test-project",
1041
+ project: "__test-img__",
945
1042
  image_id: "abc12345",
946
1043
  });
947
1044
  expect(result.isError).toBe(false);
@@ -964,7 +1061,7 @@ describe("ledgerHandlers", () => {
964
1061
  },
965
1062
  });
966
1063
  const result = await sessionViewImageHandler({
967
- project: "test-project",
1064
+ project: "__test-img__",
968
1065
  image_id: "nonexistent",
969
1066
  });
970
1067
  expect(result.isError).toBe(true);
@@ -976,7 +1073,7 @@ describe("ledgerHandlers", () => {
976
1073
  metadata: {},
977
1074
  });
978
1075
  const result = await sessionViewImageHandler({
979
- project: "test-project",
1076
+ project: "__test-img__",
980
1077
  image_id: "any-id",
981
1078
  });
982
1079
  expect(result.isError).toBe(true);
@@ -997,7 +1094,7 @@ describe("ledgerHandlers", () => {
997
1094
  },
998
1095
  });
999
1096
  const result = await sessionViewImageHandler({
1000
- project: "test-project",
1097
+ project: "__test-img__",
1001
1098
  image_id: "abc12345",
1002
1099
  });
1003
1100
  expect(result.isError).toBe(true);
@@ -1018,7 +1115,7 @@ describe("ledgerHandlers", () => {
1018
1115
  },
1019
1116
  });
1020
1117
  const result = await sessionViewImageHandler({
1021
- project: "test-project",
1118
+ project: "__test-img__",
1022
1119
  image_id: "abc12345",
1023
1120
  });
1024
1121
  expect(result.isError).toBe(false);
@@ -1028,7 +1125,7 @@ describe("ledgerHandlers", () => {
1028
1125
  // --- Input Validation ---
1029
1126
  it("returns error for invalid args (missing image_id)", async () => {
1030
1127
  const result = await sessionViewImageHandler({
1031
- project: "test-project",
1128
+ project: "__test-img__",
1032
1129
  });
1033
1130
  expect(result.isError).toBe(true);
1034
1131
  });
@@ -1154,13 +1251,13 @@ describe("ledgerHandlers", () => {
1154
1251
  });
1155
1252
  it("sessionForgetMemoryHandler includes 'tombstoned' in soft-delete response", async () => {
1156
1253
  const result = await sessionForgetMemoryHandler({
1157
- memory_id: "uuid-test",
1254
+ memory_id: "550e8400-e29b-41d4-a716-446655440001",
1158
1255
  });
1159
1256
  expect(result.content[0].text).toContain("tombstoned");
1160
1257
  });
1161
1258
  it("sessionForgetMemoryHandler mentions hard_delete option in soft-delete response", async () => {
1162
1259
  const result = await sessionForgetMemoryHandler({
1163
- memory_id: "uuid-test",
1260
+ memory_id: "550e8400-e29b-41d4-a716-446655440001",
1164
1261
  });
1165
1262
  expect(result.content[0].text).toContain("hard_delete: true");
1166
1263
  });
@@ -89,11 +89,24 @@ const MEMORY_BOUNDARY_SUFFIX = '\n</prism_memory>';
89
89
  * After saving, generates an embedding vector for the entry via fire-and-forget.
90
90
  */
91
91
  import { computeEffectiveImportance, recordMemoryAccess } from "../utils/cognitiveMemory.js";
92
- import { formatInferenceMetrics, resetInferenceMetrics } from "../utils/inferenceMetrics.js";
92
+ import { formatInferenceMetrics, resetInferenceMetrics, getInferenceSnapshot } from "../utils/inferenceMetrics.js";
93
93
  export async function sessionSaveLedgerHandler(args) {
94
94
  if (!isSessionSaveLedgerArgs(args)) {
95
95
  throw new Error("Invalid arguments for session_save_ledger");
96
96
  }
97
+ // Host-agnostic context gate: any host that calls save_ledger without first
98
+ // loading context (on any host type) gets a structured error instead of a
99
+ // silently wrong write. Does NOT gate safety — prism_infer handles that.
100
+ let _saveLedgerGateWarning;
101
+ {
102
+ const { requireContextLoaded } = await import("../session/sessionContext.js");
103
+ const gate = requireContextLoaded(args.conversation_id);
104
+ if (gate !== null && gate.blocked) {
105
+ return { content: [{ type: "text", text: gate.error }], isError: true };
106
+ }
107
+ if (gate !== null && !gate.blocked)
108
+ _saveLedgerGateWarning = gate.warning;
109
+ }
97
110
  // SECURITY: Sanitize all text fields to prevent stored prompt injection
98
111
  let project = args.project;
99
112
  const conversation_id = args.conversation_id;
@@ -234,7 +247,8 @@ export async function sessionSaveLedgerHandler(args) {
234
247
  return {
235
248
  content: [{
236
249
  type: "text",
237
- text: `✅ Session ledger saved for project "${project}"\n` +
250
+ text: (_saveLedgerGateWarning ? `⚠️ ${_saveLedgerGateWarning}\n\n` : "") +
251
+ `✅ Session ledger saved for project "${project}"\n` +
238
252
  `Summary: ${summary}\n` +
239
253
  (todos?.length ? `TODOs: ${todos.length} items\n` : "") +
240
254
  (files_changed?.length ? `Files changed: ${files_changed.length}\n` : "") +
@@ -250,6 +264,16 @@ export async function sessionSaveHandoffHandler(args, server) {
250
264
  if (!isSessionSaveHandoffArgs(args)) {
251
265
  throw new Error("Invalid arguments for session_save_handoff");
252
266
  }
267
+ let _saveHandoffGateWarning;
268
+ {
269
+ const { requireContextLoaded } = await import("../session/sessionContext.js");
270
+ const gate = requireContextLoaded(args.conversation_id);
271
+ if (gate !== null && gate.blocked) {
272
+ return { content: [{ type: "text", text: gate.error }], isError: true };
273
+ }
274
+ if (gate !== null && !gate.blocked)
275
+ _saveHandoffGateWarning = gate.warning;
276
+ }
253
277
  // SECURITY: Sanitize all text fields to prevent stored prompt injection
254
278
  const project = args.project;
255
279
  const expected_version = args.expected_version;
@@ -553,22 +577,23 @@ export async function sessionSaveHandoffHandler(args, server) {
553
577
  }
554
578
  const metricsBlock = formatInferenceMetrics();
555
579
  // Build response text based on whether a CRDT merge occurred
556
- const responseText = isMerged
557
- ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
558
- `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
559
- (last_summary ? `Summary: ${last_summary}\n` : "") +
560
- metricsBlock +
561
- `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
562
- `to maintain concurrency control.`
563
- : `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
564
- `(version: ${newVersion})\n` +
565
- (last_summary ? `Last summary: ${last_summary}\n` : "") +
566
- (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
567
- (active_branch ? `Active branch: ${active_branch}\n` : "") +
568
- `📊 Embedding generation queued for semantic search.\n` +
569
- metricsBlock +
570
- `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
571
- `to maintain concurrency control.`;
580
+ const responseText = (_saveHandoffGateWarning ? `⚠️ ${_saveHandoffGateWarning}\n\n` : "") +
581
+ (isMerged
582
+ ? `🔄 Auto-merged conflict for "${project}" (v${expected_version} → v${newVersion})\n` +
583
+ `Strategy: ${JSON.stringify(mergeStrategy)}\n` +
584
+ (last_summary ? `Summary: ${last_summary}\n` : "") +
585
+ metricsBlock +
586
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
587
+ `to maintain concurrency control.`
588
+ : `✅ Handoff ${data.status || "saved"} for project "${project}" ` +
589
+ `(version: ${newVersion})\n` +
590
+ (last_summary ? `Last summary: ${last_summary}\n` : "") +
591
+ (open_todos?.length ? `Open TODOs: ${open_todos.length} items\n` : "") +
592
+ (active_branch ? `Active branch: ${active_branch}\n` : "") +
593
+ `📊 Embedding generation queued for semantic search.\n` +
594
+ metricsBlock +
595
+ `\n🔑 Remember: pass expected_version: ${newVersion} on your next save ` +
596
+ `to maintain concurrency control.`);
572
597
  return {
573
598
  content: [{
574
599
  type: "text",
@@ -581,10 +606,17 @@ export async function sessionLoadContextHandler(args) {
581
606
  if (!isSessionLoadContextArgs(args)) {
582
607
  throw new Error("Invalid arguments for session_load_context");
583
608
  }
584
- resetInferenceMetrics();
585
- 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
609
+ // T3 fix: only reset metrics at true session start (totalCalls==0), not on GATE 5 mid-session
610
+ // reloads. Resetting on every call wipes accumulated delegation metrics right before
611
+ // session_save_ledger renders them, making the 📊 block show near-zero after any reload.
612
+ if (getInferenceSnapshot().totalCalls === 0) {
613
+ resetInferenceMetrics();
614
+ }
615
+ const { project, level = "standard", role, conversation_id: convId } = args;
616
+ // T6 fix: explicit Number() coercion prevents string "2000" from later concatenating instead of adding
617
+ const _maxTokensArg = Number(args.max_tokens);
618
+ const _maxTokensSetting = parseInt(await getSetting("max_tokens", "0"), 10);
619
+ const maxTokens = (_maxTokensArg > 0 ? _maxTokensArg : undefined) ?? (_maxTokensSetting > 0 ? _maxTokensSetting : undefined);
588
620
  const agentName = await getSetting("agent_name", "");
589
621
  const validLevels = ["quick", "standard", "deep"];
590
622
  if (!validLevels.includes(level)) {
@@ -600,12 +632,41 @@ export async function sessionLoadContextHandler(args) {
600
632
  const storage = await getStorage();
601
633
  const effectiveRole = role || await getSetting("default_role", "") || undefined;
602
634
  const data = await storage.loadContext(project, level, PRISM_USER_ID, effectiveRole); // v3.0: role with dashboard fallback
635
+ // F4 fix: inject protected skills even for fresh projects.
636
+ // Previously this returned before skill injection, leaving new projects with zero
637
+ // behavioral guardrails for the entire session. Now protected skills always load.
603
638
  if (!data) {
639
+ let freshSkillBlock = "";
640
+ try {
641
+ const { resolveSkillsForProject: resolveForFresh } = await import("./skillRouting.js");
642
+ const freshResolved = await resolveForFresh(project);
643
+ for (const entry of freshResolved.skills.filter(e => e.protected)) {
644
+ const content = await getSetting(`skill:${entry.name}`, "");
645
+ if (!content?.trim())
646
+ continue;
647
+ freshSkillBlock += `\n\n[📜 SKILL: ${entry.name}]\n${content.trim()}`;
648
+ }
649
+ }
650
+ catch {
651
+ debugLog(`[session_load_context] Fresh project skill injection failed — continuing without`);
652
+ }
653
+ if (convId) {
654
+ const { markContextLoaded } = await import("../session/sessionContext.js");
655
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
656
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
657
+ }
658
+ const { BOUNDARIES_TEXT: BT0, BOUNDARIES_VERSION: BV0 } = await import("../boundaries/boundaries.js");
659
+ const boundariesHeader0 = `# OPERATING BOUNDARIES (v${BV0}) — enforced server-side, shown for transparency\n` +
660
+ BT0 + "\n\n" +
661
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
662
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
604
663
  return {
605
664
  content: [{
606
665
  type: "text",
607
- text: `No session context found for project "${project}" at level ${level}.\n` +
608
- `This project has no previous session history. Starting fresh.`,
666
+ text: boundariesHeader0 +
667
+ `No session context found for project "${project}" at level ${level}.\n` +
668
+ `This project has no previous session history. Starting fresh.` +
669
+ freshSkillBlock,
609
670
  }],
610
671
  isError: false,
611
672
  };
@@ -855,6 +916,10 @@ export async function sessionLoadContextHandler(args) {
855
916
  const resolved = await resolveSkillsForProject(project);
856
917
  const sortedSkills = resolved.skills;
857
918
  const userLocalPolicy = resolved.user_local;
919
+ // F5: surface offline routing to the agent so it knows project/keyword skills may be missing
920
+ if (resolved.isOffline) {
921
+ skillBlock += `\n\n[⚠️ OFFLINE ROUTING: synalux.ai unreachable — using stale or fallback routing table. Project-specific and keyword-triggered skills may be missing. Universal protected skills still load.]`;
922
+ }
858
923
  let synaluxContent = {};
859
924
  if (SYNALUX_CONFIGURED && storage && typeof storage.fetchSkillContent === "function") {
860
925
  const missing = sortedSkills.map(s => s.name).filter(n => !loadedSkills.includes(n));
@@ -994,12 +1059,8 @@ export async function sessionLoadContextHandler(args) {
994
1059
  behavWarnings.map(w => `- ${w.summary} (importance: ${w.importance})`).join("\n");
995
1060
  behavBlock = [...rawBlock].slice(0, 2000).join('');
996
1061
  }
997
- let responseText = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n${formattedContext.trim()}${splitBrainWarning}${driftReport}${briefingBlock}${sdmRecallBlock}${greetingBlock}${visualMemoryBlock}${behavBlock}${skillBlock}${versionNote}`;
998
1062
  // ─── v9.4.7: ABA Precision Protocol (foundational) ────────
999
- // 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` +
1063
+ const abaProtocol = `\n\n[🧠 ABA PRECISION PROTOCOL]\n` +
1003
1064
  `Rule 1 — Observable Goals: Every task must have a measurable, verifiable outcome. State the specific result.\n` +
1004
1065
  `Rule 2 — Precise Execution: One step at a time. Verify each step. If it fails → STOP → fix → verify → then continue.\n` +
1005
1066
  `Rule 3 — No Reinforcement of Errors: Never repeat the same mistake twice. When the user says something is wrong, read the actual code/data FIRST before forming an opinion.\n` +
@@ -1007,17 +1068,79 @@ export async function sessionLoadContextHandler(args) {
1007
1068
  `Rule 5 — Fix Without Asking: When you see a bug, fix it immediately. Do NOT ask "would you like me to fix that?" — just fix it.\n` +
1008
1069
  `Rule 6 — Action Intent: When user says "fix/run/open/deploy", they want ACTION not a tutorial. Ask for specific info needed in 1-2 sentences, or act directly.\n` +
1009
1070
  `Rule 7 — Tool Redirect: When user asks to "open browser"/"run terminal"/"git push" — output ONLY the URL or command. No follow-up. No explanations. Example: "open browser" → "https://synalux.ai/dashboard"`;
1010
- // ─── v4.0: Token Budget Truncation ─────────────────────────
1011
- // 1 token 4 chars heuristic. Truncate if response exceeds budget.
1071
+ // T5 structural truncation: assemble sections in priority order so budget truncation
1072
+ // drops the LEAST critical sections first (session history), never skills.
1073
+ // Priority high→low: ABA | skills | behavioral warnings | version | drift | briefing | SDM | history
1074
+ // The header + critical rules are always at the top; history is appended last.
1075
+ const criticalPrefix = `${MEMORY_BOUNDARY_PREFIX}📋 Session context for "${project}" (${level}):\n\n` +
1076
+ abaProtocol +
1077
+ behavBlock +
1078
+ skillBlock +
1079
+ versionNote +
1080
+ greetingBlock +
1081
+ splitBrainWarning;
1082
+ const lowerPriority = driftReport +
1083
+ briefingBlock +
1084
+ sdmRecallBlock +
1085
+ visualMemoryBlock;
1086
+ const historySection = formattedContext.trim()
1087
+ ? `\n\n---\n\n📋 Historical Context:\n${formattedContext.trim()}`
1088
+ : "";
1089
+ // ─── v4.0: Token Budget Truncation (T5 structural version) ──
1090
+ // Drops low-priority sections whole (no mid-slice) before falling back
1091
+ // to trimming the history block. Protected sections (skills, ABA) always survive.
1092
+ // T1: use ~3.5 chars/token (better for emoji+code payload) instead of flat 4.
1012
1093
  if (maxTokens && maxTokens > 0) {
1013
- const maxChars = maxTokens * 4;
1094
+ const maxChars = Math.floor(maxTokens * 3.5);
1095
+ const droppedSections = [];
1096
+ let responseText = criticalPrefix + lowerPriority + historySection;
1014
1097
  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)`);
1098
+ // Drop low-priority sections first (SDM recall, briefing, drift)
1099
+ responseText = criticalPrefix + historySection;
1100
+ droppedSections.push("briefing", "drift-report", "sdm-recall");
1101
+ debugLog(`[session_load_context] T5: dropped low-priority sections (${droppedSections.join(", ")})`);
1102
+ if (responseText.length > maxChars) {
1103
+ // History too long — trim it, preserving all critical sections
1104
+ const histAllowed = Math.max(0, maxChars - criticalPrefix.length - 200);
1105
+ const trimmedHistory = histAllowed > 0
1106
+ ? `\n\n---\n\n📋 Historical Context (trimmed):\n${formattedContext.trim().slice(0, histAllowed)}…`
1107
+ : "";
1108
+ droppedSections.push("partial-history");
1109
+ responseText = criticalPrefix + trimmedHistory;
1110
+ debugLog(`[session_load_context] T5: trimmed history to ${histAllowed} chars`);
1111
+ }
1112
+ }
1113
+ if (droppedSections.length > 0) {
1114
+ responseText += `\n\n[ℹ️ Sections omitted to fit token budget (${maxTokens} tokens): ${droppedSections.join(", ")}. Skills and behavioral rules were preserved.]`;
1017
1115
  }
1116
+ if (convId) {
1117
+ const { markContextLoaded } = await import("../session/sessionContext.js");
1118
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1119
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
1120
+ }
1121
+ const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV } = await import("../boundaries/boundaries.js");
1122
+ const boundariesHeader = `# OPERATING BOUNDARIES (v${BV}) — enforced server-side, shown for transparency\n` +
1123
+ BOUNDARIES_TEXT + "\n\n" +
1124
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1125
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1126
+ return {
1127
+ content: [{ type: "text", text: boundariesHeader + responseText + MEMORY_BOUNDARY_SUFFIX }],
1128
+ isError: false,
1129
+ };
1018
1130
  }
1131
+ let responseText = criticalPrefix + lowerPriority + historySection;
1132
+ if (convId) {
1133
+ const { markContextLoaded } = await import("../session/sessionContext.js");
1134
+ const { BOUNDARIES_VERSION } = await import("../boundaries/boundaries.js");
1135
+ markContextLoaded(convId, project, BOUNDARIES_VERSION);
1136
+ }
1137
+ const { BOUNDARIES_TEXT, BOUNDARIES_VERSION: BV2 } = await import("../boundaries/boundaries.js");
1138
+ const boundariesHeader2 = `# OPERATING BOUNDARIES (v${BV2}) — enforced server-side, shown for transparency\n` +
1139
+ BOUNDARIES_TEXT + "\n\n" +
1140
+ `# NOTE FOR NON-CLAUDE HOSTS: these boundaries run in code on every prism_infer call.\n` +
1141
+ `# Reserved-category requests are routed to cloud or refused — not by instruction.\n\n---\n\n`;
1019
1142
  return {
1020
- content: [{ type: "text", text: responseText + MEMORY_BOUNDARY_SUFFIX }],
1143
+ content: [{ type: "text", text: boundariesHeader2 + responseText + MEMORY_BOUNDARY_SUFFIX }],
1021
1144
  isError: false,
1022
1145
  };
1023
1146
  }