@cortexkit/opencode-magic-context 0.38.1 → 0.40.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.
Files changed (172) hide show
  1. package/README.md +57 -4
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/profiles.d.ts +22 -0
  4. package/dist/config/profiles.d.ts.map +1 -0
  5. package/dist/config/project-security.d.ts +14 -6
  6. package/dist/config/project-security.d.ts.map +1 -1
  7. package/dist/config/raw-loader.d.ts +42 -0
  8. package/dist/config/raw-loader.d.ts.map +1 -0
  9. package/dist/config/schema/magic-context.d.ts +966 -531
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  12. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  14. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  16. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  18. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  20. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  22. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  24. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  26. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  28. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  30. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  31. package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
  32. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
  34. package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
  35. package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
  36. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  37. package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
  38. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  39. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  40. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  41. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  42. package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
  43. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  45. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  47. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  48. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage-db.d.ts +2 -2
  50. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  52. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  54. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  56. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  59. package/dist/features/magic-context/storage.d.ts +1 -1
  60. package/dist/features/magic-context/storage.d.ts.map +1 -1
  61. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  62. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  63. package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
  64. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  65. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  66. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  68. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  70. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  72. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  76. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  78. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  80. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
  82. package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/inject-compartments.d.ts +13 -1
  86. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  88. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  89. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  90. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  92. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  94. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  97. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/strip-content.d.ts +6 -0
  100. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  102. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  104. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +5 -0
  106. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/transform.d.ts +9 -1
  108. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  110. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  111. package/dist/{index-5znjkjws.js → index-axpym70t.js} +879 -170
  112. package/dist/index-ehpzdy0v.js +126 -0
  113. package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +18466 -16177
  116. package/dist/model-resolution-r9jbcz86.js +15 -0
  117. package/dist/plugin/dream-timer.d.ts +3 -0
  118. package/dist/plugin/dream-timer.d.ts.map +1 -1
  119. package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
  120. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  121. package/dist/plugin/messages-transform.d.ts +14 -6
  122. package/dist/plugin/messages-transform.d.ts.map +1 -1
  123. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  124. package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-dmyvqekw.js} +2 -3
  125. package/dist/shared/jsonc-edit.d.ts +6 -1
  126. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  127. package/dist/shared/model-resolution.d.ts +53 -0
  128. package/dist/shared/model-resolution.d.ts.map +1 -0
  129. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  130. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  131. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  132. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  133. package/dist/shared/rpc-types.d.ts +2 -0
  134. package/dist/shared/rpc-types.d.ts.map +1 -1
  135. package/dist/shared/rpc-utils.d.ts +12 -1
  136. package/dist/shared/rpc-utils.d.ts.map +1 -1
  137. package/dist/shared/status-detail-text.d.ts +4 -0
  138. package/dist/shared/status-detail-text.d.ts.map +1 -0
  139. package/dist/shared/subagent-runner.d.ts +2 -1
  140. package/dist/shared/subagent-runner.d.ts.map +1 -1
  141. package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
  142. package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
  143. package/dist/tui/data/context-db.d.ts.map +1 -1
  144. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  145. package/package.json +4 -2
  146. package/src/config/index.test.ts +434 -19
  147. package/src/config/index.ts +67 -33
  148. package/src/config/profiles.ts +106 -0
  149. package/src/config/project-security.hostile-matrix.test.ts +480 -0
  150. package/src/config/project-security.test.ts +43 -5
  151. package/src/config/project-security.ts +151 -22
  152. package/src/config/raw-loader.test.ts +424 -0
  153. package/src/config/raw-loader.ts +490 -0
  154. package/src/config/schema/magic-context.test.ts +272 -2
  155. package/src/config/schema/magic-context.ts +342 -49
  156. package/src/shared/jsonc-edit.ts +74 -1
  157. package/src/shared/model-resolution.test.ts +170 -0
  158. package/src/shared/model-resolution.ts +227 -0
  159. package/src/shared/model-suggestion-retry.test.ts +27 -0
  160. package/src/shared/model-suggestion-retry.ts +19 -10
  161. package/src/shared/resolve-fallbacks.test.ts +13 -1
  162. package/src/shared/resolve-fallbacks.ts +19 -4
  163. package/src/shared/rpc-types.ts +2 -0
  164. package/src/shared/rpc-utils.test.ts +32 -0
  165. package/src/shared/rpc-utils.ts +38 -13
  166. package/src/shared/status-detail-text.test.ts +82 -0
  167. package/src/shared/status-detail-text.ts +57 -0
  168. package/src/shared/subagent-runner.ts +3 -1
  169. package/src/tui/data/context-db.ts +1 -0
  170. package/src/tui-compiled/data/context-db.ts +1 -0
  171. package/dist/index-td9depx5.js +0 -46
  172. package/dist/resolve-fallbacks-qf0ekmz9.js +0 -11
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
-
5
+ import { resolveHistorianModel } from "../shared/model-resolution";
6
6
  import { loadPluginConfig, loadPluginConfigDetailed } from "./index";
7
+ import { resolveConfigProfile } from "./profiles";
7
8
  import { RUST_COMPACTION_OFF_WARNING } from "./transform-mode";
8
9
 
9
10
  /**
@@ -342,6 +343,40 @@ describe("loadPluginConfig — secret redaction", () => {
342
343
  expect(w).toBeDefined();
343
344
  });
344
345
 
346
+ it("prunes only cross-harness qualifier leaves and names their full paths", () => {
347
+ const result = loadWithUserConfig(
348
+ JSON.stringify({
349
+ historian: {
350
+ two_pass: true,
351
+ opencode: {
352
+ model: "anthropic/claude-sonnet",
353
+ variant: "high",
354
+ thinking_level: "minimal",
355
+ },
356
+ pi: {
357
+ model: "github-copilot/gpt-5",
358
+ thinking_level: "medium",
359
+ variant: "fast",
360
+ },
361
+ },
362
+ }),
363
+ );
364
+
365
+ expect(result.historian?.two_pass).toBe(true);
366
+ expect(result.historian?.opencode).toEqual({
367
+ model: "anthropic/claude-sonnet",
368
+ variant: "high",
369
+ });
370
+ expect(result.historian?.pi).toEqual({
371
+ model: "github-copilot/gpt-5",
372
+ thinking_level: "medium",
373
+ });
374
+ const warnings = result.configWarnings?.join("\n") ?? "";
375
+ expect(warnings).toContain("historian.opencode.thinking_level");
376
+ expect(warnings).toContain("historian.pi.variant");
377
+ expect(warnings).not.toContain("invalid agent configuration, ignoring");
378
+ });
379
+
345
380
  it("still shows numeric and boolean invalid values (not secrets by nature)", () => {
346
381
  // Numbers/booleans in config fields are never secrets — they're
347
382
  // plain validation mistakes — so we surface them fully to help
@@ -740,25 +775,43 @@ describe("loadPluginConfig — user-only settings", () => {
740
775
  const result = loadWithUserAndProjectConfig(
741
776
  JSON.stringify({
742
777
  historian: {
743
- model: "anthropic/user-historian",
744
- fallback_models: ["anthropic/user-fallback"],
778
+ opencode: {
779
+ model: "anthropic/user-historian",
780
+ fallback_models: ["anthropic/user-fallback"],
781
+ },
782
+ pi: {
783
+ model: "github-copilot/user-historian",
784
+ fallback_models: ["github-copilot/user-fallback"],
785
+ },
745
786
  },
746
787
  }),
747
788
  JSON.stringify({
748
789
  historian: {
749
- model: "anthropic/project-historian",
750
- fallback_models: ["anthropic/project-fallback"],
790
+ opencode: {
791
+ model: "anthropic/project-historian",
792
+ fallback_models: ["anthropic/project-fallback"],
793
+ },
794
+ pi: {
795
+ model: "github-copilot/project-historian",
796
+ fallback_models: ["github-copilot/project-fallback"],
797
+ },
751
798
  temperature: 0.2,
752
799
  },
753
800
  }),
754
801
  );
755
802
 
756
- expect(result.historian?.model).toBe("anthropic/user-historian");
757
- expect(result.historian?.fallback_models).toEqual(["anthropic/user-fallback"]);
803
+ expect(result.historian?.opencode).toEqual({
804
+ model: "anthropic/user-historian",
805
+ fallback_models: ["anthropic/user-fallback"],
806
+ });
807
+ expect(result.historian?.pi).toEqual({
808
+ model: "github-copilot/user-historian",
809
+ fallback_models: ["github-copilot/user-fallback"],
810
+ });
758
811
  expect(result.historian?.temperature).toBe(0.2);
759
- expect(result.configWarnings?.join("\n")).toContain(
760
- "Ignoring historian.model/fallback_models",
761
- );
812
+ const warnings = result.configWarnings?.join("\n") ?? "";
813
+ expect(warnings).toContain("historian.opencode.model");
814
+ expect(warnings).toContain("historian.pi.model");
762
815
  });
763
816
  });
764
817
 
@@ -877,21 +930,25 @@ describe("loadPluginConfig — raw merge preserves user fields not set in projec
877
930
  JSON.stringify({ language: "tr" }),
878
931
  JSON.stringify({
879
932
  dreamer: {
880
- model: "anthropic/project-dreamer",
881
- tasks: {
882
- verify: {
883
- schedule: "0 3 * * *",
884
- model: "anthropic/project-verify",
885
- },
933
+ tasks: { verify: { schedule: "0 3 * * *" } },
934
+ opencode: {
935
+ model: "anthropic/project-dreamer",
936
+ tasks: { verify: { model: "anthropic/project-verify" } },
937
+ },
938
+ pi: {
939
+ model: "github-copilot/project-dreamer",
940
+ tasks: { verify: { model: "github-copilot/project-verify" } },
886
941
  },
887
942
  },
888
943
  }),
889
944
  );
890
945
 
891
946
  expect(result.language).toBe("tr");
892
- expect(result.dreamer?.model).toBe("anthropic/project-dreamer");
947
+ expect(result.dreamer?.opencode?.model).toBe("anthropic/project-dreamer");
948
+ expect(result.dreamer?.pi?.model).toBe("github-copilot/project-dreamer");
893
949
  expect(result.dreamer?.tasks.verify.schedule).toBe("0 3 * * *");
894
- expect(result.dreamer?.tasks.verify.model).toBe("anthropic/project-verify");
950
+ expect(result.dreamer?.opencode?.tasks?.verify?.model).toBe("anthropic/project-verify");
951
+ expect(result.dreamer?.pi?.tasks?.verify?.model).toBe("github-copilot/project-verify");
895
952
  });
896
953
 
897
954
  it("project boolean override beats user default", () => {
@@ -958,6 +1015,364 @@ describe("transform_mode resolution", () => {
958
1015
  });
959
1016
  });
960
1017
 
1018
+ describe("loadPluginConfig — user-owned model profiles", () => {
1019
+ it("merges user base, selected profile, then project config without losing profile fallback qualifiers", () => {
1020
+ const result = loadWithUserAndProjectConfig(
1021
+ JSON.stringify({
1022
+ profile: "personal",
1023
+ historian: {
1024
+ two_pass: true,
1025
+ opencode: {
1026
+ model: "anthropic/base-historian",
1027
+ fallback_models: ["anthropic/base-fallback"],
1028
+ },
1029
+ },
1030
+ dreamer: {
1031
+ opencode: { model: "anthropic/base-dreamer" },
1032
+ },
1033
+ profiles: {
1034
+ work: {
1035
+ historian: {
1036
+ opencode: {
1037
+ model: { model: "anthropic/work-historian", variant: "high" },
1038
+ fallback_models: [
1039
+ { model: "openai/work-fallback", variant: "low" },
1040
+ ],
1041
+ },
1042
+ },
1043
+ dreamer: {
1044
+ opencode: {
1045
+ model: "anthropic/work-dreamer",
1046
+ fallback_models: [
1047
+ { model: "openai/work-dreamer-fallback", variant: "medium" },
1048
+ ],
1049
+ },
1050
+ },
1051
+ sidekick: { model: "anthropic/work-sidekick" },
1052
+ },
1053
+ personal: {
1054
+ historian: { opencode: { model: "anthropic/personal-historian" } },
1055
+ },
1056
+ },
1057
+ }),
1058
+ JSON.stringify({
1059
+ profile: "work",
1060
+ memory: { enabled: false },
1061
+ }),
1062
+ );
1063
+
1064
+ expect(result.profile).toBe("work");
1065
+ expect(result.memory.enabled).toBe(false);
1066
+ expect(result.historian?.two_pass).toBe(true);
1067
+ expect(result.historian?.opencode).toEqual({
1068
+ model: { model: "anthropic/work-historian", variant: "high" },
1069
+ fallback_models: [{ model: "openai/work-fallback", variant: "low" }],
1070
+ });
1071
+ expect(result.dreamer?.opencode?.model).toBe("anthropic/work-dreamer");
1072
+ expect(result.dreamer?.opencode?.fallback_models).toEqual([
1073
+ { model: "openai/work-dreamer-fallback", variant: "medium" },
1074
+ ]);
1075
+ expect(result.sidekick?.model).toBe("anthropic/work-sidekick");
1076
+ });
1077
+
1078
+ it("uses project selection over user selection and falls back to the base on an unknown name", () => {
1079
+ const userConfig = JSON.stringify({
1080
+ profile: "personal",
1081
+ historian: { opencode: { model: "anthropic/base" } },
1082
+ profiles: {
1083
+ personal: { historian: { opencode: { model: "anthropic/personal" } } },
1084
+ work: { historian: { opencode: { model: "anthropic/work" } } },
1085
+ },
1086
+ });
1087
+
1088
+ const userDefault = loadWithUserAndProjectConfig(userConfig, "{}");
1089
+ const projectOverride = loadWithUserAndProjectConfig(userConfig, '{"profile":"work"}');
1090
+ const unknownProject = loadWithUserAndProjectConfig(userConfig, '{"profile":"missing"}');
1091
+
1092
+ expect(userDefault.profile).toBe("personal");
1093
+ expect(userDefault.historian?.opencode?.model).toBe("anthropic/personal");
1094
+ expect(projectOverride.profile).toBe("work");
1095
+ expect(projectOverride.historian?.opencode?.model).toBe("anthropic/work");
1096
+ expect(unknownProject.profile).toBeUndefined();
1097
+ expect(unknownProject.enabled).toBe(true);
1098
+ expect(unknownProject.historian?.opencode?.model).toBe("anthropic/base");
1099
+ expect(unknownProject.configWarnings?.join("\n")).toContain(
1100
+ 'Unknown profile "missing" selected by project config',
1101
+ );
1102
+ });
1103
+
1104
+ it("rejects invalid profile definitions with a warning instead of applying their fields", () => {
1105
+ const result = loadWithUserConfig(
1106
+ JSON.stringify({
1107
+ profile: "unsafe",
1108
+ historian: { opencode: { model: "anthropic/base" } },
1109
+ profiles: {
1110
+ unsafe: {
1111
+ embedding: { provider: "off" },
1112
+ },
1113
+ },
1114
+ }),
1115
+ );
1116
+
1117
+ expect(result.profile).toBeUndefined();
1118
+ expect(result.embedding.provider).toBe("local");
1119
+ expect(result.historian?.opencode?.model).toBe("anthropic/base");
1120
+ expect(result.configWarnings?.join("\n")).toContain("Ignoring profiles from user config");
1121
+ });
1122
+
1123
+ it("strips hostile project profile definitions while allowing only user-owned definitions", () => {
1124
+ const result = loadWithUserAndProjectConfig(
1125
+ JSON.stringify({
1126
+ historian: { opencode: { model: "anthropic/user-base" } },
1127
+ }),
1128
+ JSON.stringify({
1129
+ profile: "work",
1130
+ profiles: {
1131
+ work: {
1132
+ historian: { opencode: { model: "attacker/project-profile" } },
1133
+ },
1134
+ },
1135
+ }),
1136
+ );
1137
+
1138
+ expect(result.profile).toBeUndefined();
1139
+ expect(result.historian?.opencode?.model).toBe("anthropic/user-base");
1140
+ expect(result.configWarnings?.join("\n")).toContain(
1141
+ "Ignoring profiles from project config",
1142
+ );
1143
+ });
1144
+
1145
+ it("falls back loudly for inherited profile names without changing the resolved base config", () => {
1146
+ const userConfig = JSON.stringify({
1147
+ historian: { opencode: { model: "anthropic/base", fallback_models: ["openai/base"] } },
1148
+ profiles: { known: { historian: { opencode: { model: "anthropic/known" } } } },
1149
+ });
1150
+ const base = loadWithUserConfig(userConfig);
1151
+ const { configWarnings: _baseWarnings, ...baseWithoutWarnings } = base;
1152
+ const hostileNames = [
1153
+ "__proto__",
1154
+ "constructor",
1155
+ "prototype",
1156
+ "toString",
1157
+ "hasOwnProperty",
1158
+ "valueOf",
1159
+ ];
1160
+
1161
+ for (const name of hostileNames) {
1162
+ const resolved = loadWithUserAndProjectConfig(
1163
+ userConfig,
1164
+ JSON.stringify({ profile: name }),
1165
+ );
1166
+ const { configWarnings: _warnings, ...resolvedWithoutWarnings } = resolved;
1167
+
1168
+ expect(resolved.profile).toBeUndefined();
1169
+ expect(resolved.configWarnings?.join("\n")).toContain(
1170
+ `Unknown profile "${name}" selected by project config`,
1171
+ );
1172
+ expect(resolvedWithoutWarnings).toEqual(baseWithoutWarnings);
1173
+ }
1174
+ });
1175
+
1176
+ it("ignores a profile inherited from a polluted Object prototype", () => {
1177
+ const profileName = "profile_overlay_pollution_canary";
1178
+ const previousDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, profileName);
1179
+ const userBase = { historian: { opencode: { model: "anthropic/base" } } };
1180
+ // Null-prototype input isolates lookup behavior from strict schema validation of inherited data.
1181
+ const profileOpenCode = Object.assign(Object.create(null), {
1182
+ model: "anthropic/known",
1183
+ });
1184
+ const profileHistorian = Object.assign(Object.create(null), {
1185
+ opencode: profileOpenCode,
1186
+ });
1187
+ const knownProfile = Object.assign(Object.create(null), {
1188
+ historian: profileHistorian,
1189
+ });
1190
+ const profiles = Object.assign(Object.create(null), {
1191
+ known: knownProfile,
1192
+ }) as Record<string, unknown>;
1193
+ Object.defineProperty(Object.prototype, profileName, {
1194
+ value: { historian: { opencode: { model: "attacker/inherited-overlay" } } },
1195
+ enumerable: true,
1196
+ configurable: true,
1197
+ });
1198
+
1199
+ try {
1200
+ const resolution = resolveConfigProfile({
1201
+ userRaw: {
1202
+ ...userBase,
1203
+ profiles,
1204
+ },
1205
+ projectRaw: { profile: profileName },
1206
+ });
1207
+
1208
+ expect(resolution.activeProfile).toBeUndefined();
1209
+ expect(resolution.overlay).toEqual({});
1210
+ expect(resolution.userBase).toEqual(userBase);
1211
+ expect(resolution.projectBase).toEqual({});
1212
+ expect(resolution.warnings).toEqual([
1213
+ `Unknown profile "${profileName}" selected by project config; using base config without a profile.`,
1214
+ ]);
1215
+ } finally {
1216
+ if (previousDescriptor) {
1217
+ Object.defineProperty(Object.prototype, profileName, previousDescriptor);
1218
+ } else {
1219
+ delete (Object.prototype as Record<string, unknown>)[profileName];
1220
+ }
1221
+ }
1222
+ });
1223
+
1224
+ it("treats empty, null, and non-string project selectors as no selection", () => {
1225
+ const userConfig = JSON.stringify({
1226
+ profile: "personal",
1227
+ historian: { opencode: { model: "anthropic/base" } },
1228
+ profiles: {
1229
+ personal: { historian: { opencode: { model: "anthropic/personal" } } },
1230
+ },
1231
+ });
1232
+ const invalidProjectSelectors = ["", null, { name: "work" }];
1233
+
1234
+ for (const profile of invalidProjectSelectors) {
1235
+ const result = loadWithUserAndProjectConfig(userConfig, JSON.stringify({ profile }));
1236
+
1237
+ expect(result.profile).toBe("personal");
1238
+ expect(result.historian?.opencode?.model).toBe("anthropic/personal");
1239
+ expect(result.configWarnings?.join("\n")).toContain(
1240
+ "Ignoring invalid profile selection from project config",
1241
+ );
1242
+ }
1243
+ });
1244
+
1245
+ it("preserves an untouched historian Pi block when a profile overlays OpenCode", () => {
1246
+ const piBase = {
1247
+ model: { model: "github-copilot/base", thinking_level: "high" },
1248
+ fallback_models: [
1249
+ { model: "openai/base-fallback", thinking_level: "minimal" },
1250
+ { model: "github-copilot/second-base-fallback", thinking_level: "medium" },
1251
+ ],
1252
+ thinking_level: "high",
1253
+ };
1254
+ const result = loadWithUserConfig(
1255
+ JSON.stringify({
1256
+ profile: "work",
1257
+ historian: {
1258
+ opencode: { model: "anthropic/base-opencode" },
1259
+ pi: piBase,
1260
+ },
1261
+ profiles: {
1262
+ work: {
1263
+ historian: { opencode: { model: "anthropic/work-opencode" } },
1264
+ },
1265
+ },
1266
+ }),
1267
+ );
1268
+
1269
+ expect(result.historian?.opencode).toEqual({ model: "anthropic/work-opencode" });
1270
+ expect(result.historian?.pi).toEqual(piBase);
1271
+ });
1272
+
1273
+ it("replaces a base fallback_models array wholesale when a profile provides one", () => {
1274
+ const result = loadWithUserConfig(
1275
+ JSON.stringify({
1276
+ profile: "work",
1277
+ historian: {
1278
+ opencode: {
1279
+ model: "anthropic/base",
1280
+ fallback_models: [
1281
+ { model: "openai/base-first", variant: "low" },
1282
+ { model: "anthropic/base-second", variant: "high" },
1283
+ ],
1284
+ },
1285
+ },
1286
+ profiles: {
1287
+ work: {
1288
+ historian: {
1289
+ opencode: {
1290
+ fallback_models: [{ model: "google/work-only", variant: "medium" }],
1291
+ },
1292
+ },
1293
+ },
1294
+ },
1295
+ }),
1296
+ );
1297
+
1298
+ expect(result.historian?.opencode?.fallback_models).toEqual([
1299
+ { model: "google/work-only", variant: "medium" },
1300
+ ]);
1301
+ });
1302
+
1303
+ it("resolves profiles independently for two projects in one process", () => {
1304
+ const xdg = mkdtempSync(join(tmpdir(), "mc-profile-isolation-user-"));
1305
+ const projectA = mkdtempSync(join(tmpdir(), "mc-profile-isolation-a-"));
1306
+ const projectB = mkdtempSync(join(tmpdir(), "mc-profile-isolation-b-"));
1307
+ const previousXdg = process.env.XDG_CONFIG_HOME;
1308
+ mkdirSync(join(xdg, "cortexkit"), { recursive: true });
1309
+ mkdirSync(join(projectA, ".cortexkit"), { recursive: true });
1310
+ mkdirSync(join(projectB, ".cortexkit"), { recursive: true });
1311
+ writeFileSync(
1312
+ join(xdg, "cortexkit", "magic-context.jsonc"),
1313
+ JSON.stringify({
1314
+ profiles: {
1315
+ work: { historian: { opencode: { model: "anthropic/work" } } },
1316
+ personal: {
1317
+ historian: { opencode: { model: "anthropic/personal" } },
1318
+ },
1319
+ },
1320
+ }),
1321
+ );
1322
+ writeFileSync(join(projectA, ".cortexkit", "magic-context.jsonc"), '{"profile":"work"}');
1323
+ writeFileSync(
1324
+ join(projectB, ".cortexkit", "magic-context.jsonc"),
1325
+ '{"profile":"personal"}',
1326
+ );
1327
+ process.env.XDG_CONFIG_HOME = xdg;
1328
+ try {
1329
+ const work = loadPluginConfig(projectA);
1330
+ const personal = loadPluginConfig(projectB);
1331
+ const workAgain = loadPluginConfig(projectA);
1332
+ expect(work.profile).toBe("work");
1333
+ expect(personal.profile).toBe("personal");
1334
+ expect(workAgain.profile).toBe("work");
1335
+ expect(work.historian?.opencode?.model).toBe("anthropic/work");
1336
+ expect(personal.historian?.opencode?.model).toBe("anthropic/personal");
1337
+ expect(workAgain.historian?.opencode?.model).toBe("anthropic/work");
1338
+ } finally {
1339
+ if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
1340
+ else process.env.XDG_CONFIG_HOME = previousXdg;
1341
+ rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
1342
+ rmSync(projectA, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
1343
+ rmSync(projectB, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
1344
+ }
1345
+ });
1346
+
1347
+ it("feeds the selected profile through the existing historian model resolver", () => {
1348
+ const config = loadWithUserAndProjectConfig(
1349
+ JSON.stringify({
1350
+ profiles: {
1351
+ work: {
1352
+ historian: {
1353
+ opencode: {
1354
+ model: { model: "anthropic/work", variant: "high" },
1355
+ fallback_models: [
1356
+ { model: "openai/work-fallback", variant: "low" },
1357
+ ],
1358
+ },
1359
+ },
1360
+ },
1361
+ },
1362
+ }),
1363
+ '{"profile":"work"}',
1364
+ );
1365
+
1366
+ // Profile selection changes only the normal resolved model input. The
1367
+ // existing model-switch identity path still sees canonical model IDs and
1368
+ // qualifier-distinct fallback attempts; profiles add no separate cache-bust class.
1369
+ expect(resolveHistorianModel(config, "opencode")).toEqual({
1370
+ primary: { model: "anthropic/work", qualifier: "high" },
1371
+ fallbacks: [{ model: "openai/work-fallback", qualifier: "low" }],
1372
+ });
1373
+ });
1374
+ });
1375
+
961
1376
  describe("loadPluginConfigDetailed — prompt-surface registration owner", () => {
962
1377
  it("captures the user default before project guidance routing is merged", () => {
963
1378
  const xdg = mkdtempSync(join(tmpdir(), "mc-config-prompt-surface-"));