@cortexkit/opencode-magic-context 0.33.1 → 0.34.1

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 (240) hide show
  1. package/README.md +20 -3
  2. package/dist/config/agent-disable.d.ts +22 -0
  3. package/dist/config/agent-disable.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/migrate-experimental.d.ts +2 -1
  6. package/dist/config/migrate-experimental.d.ts.map +1 -1
  7. package/dist/config/project-security.d.ts +9 -0
  8. package/dist/config/project-security.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +90 -17
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/config/transform-mode.d.ts +2 -0
  12. package/dist/config/transform-mode.d.ts.map +1 -1
  13. package/dist/{conflict-warning-hook-n48c783v.js → conflict-warning-hook-fvpfvt0w.js} +1 -1
  14. package/dist/{derive-budgets-e9j09sjr.js → derive-budgets-9crp0cvd.js} +1 -1
  15. package/dist/features/builtin-commands/commands.d.ts +1 -1
  16. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  17. package/dist/features/magic-context/compaction-marker.d.ts +57 -0
  18. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  19. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/classify.d.ts +1 -0
  21. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts +1 -0
  24. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +1 -0
  26. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +2 -0
  28. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  29. package/dist/features/magic-context/dreamer/task-executor.d.ts +4 -1
  30. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  31. package/dist/features/magic-context/dreamer/task-gates.d.ts +19 -1
  32. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  33. package/dist/features/magic-context/dreamer/task-registry.d.ts +28 -0
  34. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  35. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +7 -1
  36. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  37. package/dist/features/magic-context/dreamer/verify.d.ts +1 -0
  38. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  39. package/dist/features/magic-context/fail-closed-block.d.ts +10 -0
  40. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  41. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  42. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  43. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  44. package/dist/features/magic-context/memory/embedding-local.d.ts +7 -1
  45. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  46. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  48. package/dist/features/magic-context/memory/project-identity.d.ts +2 -6
  49. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  50. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  51. package/dist/features/magic-context/migrations.d.ts +23 -1
  52. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  53. package/dist/features/magic-context/mural/compress-cues.d.ts +1 -0
  54. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  55. package/dist/features/magic-context/mural/render-mural.d.ts.map +1 -1
  56. package/dist/features/magic-context/overflow-detection.d.ts +8 -1
  57. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  58. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  59. package/dist/features/magic-context/scheduler.d.ts.map +1 -1
  60. package/dist/features/magic-context/schema-fence-probe.d.ts +28 -0
  61. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -0
  62. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  63. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  64. package/dist/features/magic-context/storage-db.d.ts +37 -2
  65. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  66. package/dist/features/magic-context/storage-meta-persisted.d.ts +38 -4
  67. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  68. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  69. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  70. package/dist/features/magic-context/storage.d.ts +2 -2
  71. package/dist/features/magic-context/storage.d.ts.map +1 -1
  72. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/boundary-execution.d.ts +1 -1
  74. package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/cache-busting-signals.d.ts +1 -0
  76. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/child-session-spawn.d.ts +26 -0
  78. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -0
  79. package/dist/hooks/magic-context/command-handler.d.ts +3 -0
  80. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/compaction-off-transition.d.ts +96 -0
  82. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -0
  84. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/compartment-trigger.d.ts +12 -5
  90. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +47 -1
  92. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/emergency-drop.d.ts +2 -2
  94. package/dist/hooks/magic-context/event-handler.d.ts +7 -0
  95. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/event-resolvers.d.ts +13 -21
  97. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/execute-status.d.ts +5 -1
  99. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
  101. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -1
  103. package/dist/hooks/magic-context/hook-handlers.d.ts +1 -0
  104. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/hook.d.ts +14 -6
  106. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/inject-compartments.d.ts +10 -1
  108. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/live-session-state.d.ts +3 -0
  110. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
  115. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/send-session-notification.d.ts +20 -1
  117. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -0
  119. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +31 -1
  121. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/transform.d.ts +29 -2
  123. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  124. package/dist/{index-56yjaeb2.js → index-2y0ybv5x.js} +11 -1
  125. package/dist/{index-ey1bc14z.js → index-b563sga5.js} +7 -264
  126. package/dist/{index-y1c9tcm3.js → index-cn960ffd.js} +101 -20
  127. package/dist/{index-xyaekybw.js → index-dtas91s5.js} +6 -4
  128. package/dist/index-fcyvznch.js +279 -0
  129. package/dist/{index-63dq2g4d.js → index-ry8rh8cv.js} +138 -24
  130. package/dist/{index-c2gahrks.js → index-x8spt5wy.js} +1682 -893
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/index.js +1677 -723
  133. package/dist/plugin/dream-timer.d.ts +4 -2
  134. package/dist/plugin/dream-timer.d.ts.map +1 -1
  135. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  136. package/dist/plugin/hooks/create-session-hooks.d.ts +12 -1
  137. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  138. package/dist/plugin/messages-transform.d.ts +10 -0
  139. package/dist/plugin/messages-transform.d.ts.map +1 -1
  140. package/dist/plugin/rpc-handlers.d.ts +3 -7
  141. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  142. package/dist/plugin/tool-registry.d.ts +8 -0
  143. package/dist/plugin/tool-registry.d.ts.map +1 -1
  144. package/dist/{recomp-orchestrator-2yb2hfh2.js → recomp-orchestrator-k72ejkkh.js} +6 -4
  145. package/dist/send-session-notification-6njqewzk.js +20 -0
  146. package/dist/shared/conflict-detector.d.ts +38 -1
  147. package/dist/shared/conflict-detector.d.ts.map +1 -1
  148. package/dist/shared/conflict-fixer.d.ts +18 -1
  149. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  150. package/dist/shared/context-limit-provenance.d.ts +3 -0
  151. package/dist/shared/context-limit-provenance.d.ts.map +1 -0
  152. package/dist/shared/escalation-bands.d.ts +9 -0
  153. package/dist/shared/escalation-bands.d.ts.map +1 -0
  154. package/dist/shared/format-threshold.d.ts +1 -1
  155. package/dist/shared/models-dev-cache.d.ts +29 -3
  156. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  157. package/dist/shared/rpc-server.d.ts.map +1 -1
  158. package/dist/shared/rpc-types.d.ts +51 -1
  159. package/dist/shared/rpc-types.d.ts.map +1 -1
  160. package/dist/shared/rpc-utils.d.ts +19 -0
  161. package/dist/shared/rpc-utils.d.ts.map +1 -1
  162. package/dist/shared/storage-permissions.d.ts +5 -0
  163. package/dist/shared/storage-permissions.d.ts.map +1 -0
  164. package/dist/shared/tui-runtime-specifiers.d.ts +20 -0
  165. package/dist/shared/tui-runtime-specifiers.d.ts.map +1 -0
  166. package/dist/{storage-meta-session-td6bwtky.js → storage-meta-session-xt41zbw4.js} +2 -1
  167. package/dist/{task-config-5nx3qfz8.js → task-config-gbp7r23g.js} +1 -1
  168. package/dist/tui/compaction-off.d.ts +13 -0
  169. package/dist/tui/compaction-off.d.ts.map +1 -0
  170. package/dist/tui/data/context-db.d.ts.map +1 -1
  171. package/dist/tui-compiled/compaction-off.d.ts +13 -0
  172. package/dist/tui-compiled/compaction-off.d.ts.map +1 -0
  173. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  174. package/package.json +6 -1
  175. package/src/agents/dreamer.ts +68 -0
  176. package/src/agents/hidden-agent-registrations.ts +331 -0
  177. package/src/agents/historian.ts +3 -0
  178. package/src/agents/language-directive.test.ts +138 -0
  179. package/src/agents/language-directive.ts +121 -0
  180. package/src/agents/magic-context-prompt.language.test.ts +35 -0
  181. package/src/agents/magic-context-prompt.test.ts +292 -0
  182. package/src/agents/magic-context-prompt.ts +182 -0
  183. package/src/agents/permissions.test.ts +252 -0
  184. package/src/agents/permissions.ts +186 -0
  185. package/src/agents/sidekick.ts +1 -0
  186. package/src/agents/smart-note-compiler.ts +1 -0
  187. package/src/config/agent-disable.ts +106 -0
  188. package/src/config/compaction-accessor-guard.test.ts +82 -0
  189. package/src/config/compaction-backcompat.test.ts +52 -0
  190. package/src/config/index.test.ts +936 -0
  191. package/src/config/index.ts +601 -0
  192. package/src/config/migrate-config-location.test.ts +332 -0
  193. package/src/config/migrate-config-location.ts +635 -0
  194. package/src/config/migrate-dreamer-v2.test.ts +287 -0
  195. package/src/config/migrate-dreamer-v2.ts +336 -0
  196. package/src/config/migrate-experimental.test.ts +41 -0
  197. package/src/config/migrate-experimental.ts +185 -0
  198. package/src/config/project-security.test.ts +388 -0
  199. package/src/config/project-security.ts +611 -0
  200. package/src/config/prune-config-leaf.test.ts +78 -0
  201. package/src/config/prune-config-leaf.ts +68 -0
  202. package/src/config/removed-config-keys.test.ts +116 -0
  203. package/src/config/schema/agent-overrides.ts +45 -0
  204. package/src/config/schema/magic-context.test.ts +359 -0
  205. package/src/config/schema/magic-context.ts +991 -0
  206. package/src/config/transform-mode.test.ts +62 -0
  207. package/src/config/transform-mode.ts +34 -0
  208. package/src/config/variable.test.ts +323 -0
  209. package/src/config/variable.ts +192 -0
  210. package/src/features/magic-context/defaults.ts +1 -0
  211. package/src/features/magic-context/dreamer/cron.ts +243 -0
  212. package/src/features/magic-context/dreamer/task-registry.ts +151 -0
  213. package/src/shared/conflict-detector.test.ts +121 -0
  214. package/src/shared/conflict-detector.ts +50 -3
  215. package/src/shared/conflict-fixer.test.ts +309 -0
  216. package/src/shared/conflict-fixer.ts +82 -5
  217. package/src/shared/context-limit-provenance.ts +6 -0
  218. package/src/shared/escalation-bands.test.ts +23 -0
  219. package/src/shared/escalation-bands.ts +18 -0
  220. package/src/shared/format-threshold.ts +1 -1
  221. package/src/shared/models-dev-cache.test.ts +171 -0
  222. package/src/shared/models-dev-cache.ts +195 -38
  223. package/src/shared/rpc-server.ts +27 -18
  224. package/src/shared/rpc-types.ts +55 -1
  225. package/src/shared/rpc-utils.test.ts +157 -0
  226. package/src/shared/rpc-utils.ts +137 -0
  227. package/src/shared/storage-permissions.ts +20 -0
  228. package/src/shared/tui-runtime-specifiers.ts +32 -0
  229. package/src/tui/compaction-off.test.ts +56 -0
  230. package/src/tui/compaction-off.ts +29 -0
  231. package/src/tui/data/context-db.ts +6 -0
  232. package/src/tui/index.tsx +113 -65
  233. package/src/tui/slots/sidebar-content.tsx +189 -91
  234. package/src/tui/tui-compiled-runtime-imports.test.ts +151 -0
  235. package/src/tui-compiled/compaction-off.ts +29 -0
  236. package/src/tui-compiled/data/context-db.ts +6 -0
  237. package/src/tui-compiled/index.tsx +558 -455
  238. package/src/tui-compiled/slots/sidebar-content.tsx +334 -169
  239. package/dist/send-session-notification-tghq9gnh.js +0 -11
  240. /package/dist/{index-v7fmc1s2.js → index-ygcng9t8.js} +0 -0
@@ -0,0 +1,936 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { loadPluginConfig, loadPluginConfigDetailed } from "./index";
7
+ import { RUST_COMPACTION_OFF_WARNING } from "./transform-mode";
8
+
9
+ /**
10
+ * Writes a magic-context.jsonc file inside a fresh temp XDG_CONFIG_HOME tree
11
+ * and runs loadPluginConfig against it. Returns warnings + parsed config.
12
+ *
13
+ * Scope directory is NOT set — we pass a unique directory that does not
14
+ * contain a project config so only the user config is loaded.
15
+ */
16
+ function loadWithUserConfig(configText: string, extraEnv: Record<string, string> = {}) {
17
+ const xdg = mkdtempSync(join(tmpdir(), "mc-config-test-"));
18
+ // Hard cutover: the loader reads user config from <XDG>/cortexkit/.
19
+ const configDir = join(xdg, "cortexkit");
20
+ const fs = require("node:fs") as typeof import("node:fs");
21
+ fs.mkdirSync(configDir, { recursive: true });
22
+ writeFileSync(join(configDir, "magic-context.jsonc"), configText, "utf-8");
23
+
24
+ const origXdg = process.env.XDG_CONFIG_HOME;
25
+ const savedEnv: Record<string, string | undefined> = {};
26
+ for (const [k, v] of Object.entries(extraEnv)) {
27
+ savedEnv[k] = process.env[k];
28
+ process.env[k] = v;
29
+ }
30
+ process.env.XDG_CONFIG_HOME = xdg;
31
+
32
+ // Use a directory that definitely has no project config so only the
33
+ // user config feeds the loader. We use a sibling temp directory.
34
+ const projectDir = mkdtempSync(join(tmpdir(), "mc-config-proj-"));
35
+ try {
36
+ return loadPluginConfig(projectDir);
37
+ } finally {
38
+ if (origXdg === undefined) {
39
+ delete process.env.XDG_CONFIG_HOME;
40
+ } else {
41
+ process.env.XDG_CONFIG_HOME = origXdg;
42
+ }
43
+ for (const [k, v] of Object.entries(savedEnv)) {
44
+ if (v === undefined) delete process.env[k];
45
+ else process.env[k] = v;
46
+ }
47
+ try {
48
+ rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
49
+ } catch {
50
+ /* Ignore EBUSY on Windows */
51
+ }
52
+ try {
53
+ rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
54
+ } catch {
55
+ /* Ignore EBUSY on Windows */
56
+ }
57
+ }
58
+ }
59
+
60
+ function loadWithUserAndProjectConfig(
61
+ userConfigText: string,
62
+ projectConfigText: string,
63
+ extraEnv: Record<string, string> = {},
64
+ ) {
65
+ const xdg = mkdtempSync(join(tmpdir(), "mc-config-test-"));
66
+ const projectDir = mkdtempSync(join(tmpdir(), "mc-config-proj-"));
67
+ const fs = require("node:fs") as typeof import("node:fs");
68
+ // Hard cutover: user config at <XDG>/cortexkit/, project at <root>/.cortexkit/.
69
+ const configDir = join(xdg, "cortexkit");
70
+ fs.mkdirSync(configDir, { recursive: true });
71
+ fs.mkdirSync(join(projectDir, ".cortexkit"), { recursive: true });
72
+ writeFileSync(join(configDir, "magic-context.jsonc"), userConfigText, "utf-8");
73
+ writeFileSync(
74
+ join(projectDir, ".cortexkit", "magic-context.jsonc"),
75
+ projectConfigText,
76
+ "utf-8",
77
+ );
78
+
79
+ const origXdg = process.env.XDG_CONFIG_HOME;
80
+ const savedEnv: Record<string, string | undefined> = {};
81
+ for (const [k, v] of Object.entries(extraEnv)) {
82
+ savedEnv[k] = process.env[k];
83
+ process.env[k] = v;
84
+ }
85
+ process.env.XDG_CONFIG_HOME = xdg;
86
+
87
+ try {
88
+ return loadPluginConfig(projectDir);
89
+ } finally {
90
+ if (origXdg === undefined) {
91
+ delete process.env.XDG_CONFIG_HOME;
92
+ } else {
93
+ process.env.XDG_CONFIG_HOME = origXdg;
94
+ }
95
+ for (const [k, v] of Object.entries(savedEnv)) {
96
+ if (v === undefined) delete process.env[k];
97
+ else process.env[k] = v;
98
+ }
99
+ try {
100
+ rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
101
+ } catch {
102
+ /* Ignore EBUSY on Windows */
103
+ }
104
+ try {
105
+ rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
106
+ } catch {
107
+ /* Ignore EBUSY on Windows */
108
+ }
109
+ }
110
+ }
111
+
112
+ describe("loadPluginConfig — graduated mural config", () => {
113
+ it("adopts legacy experimental.mural at the top-level and warns once", () => {
114
+ const result = loadWithUserConfig(
115
+ JSON.stringify({
116
+ experimental: { mural: { enabled: true, model: "provider/cue-model" } },
117
+ }),
118
+ );
119
+
120
+ expect(result.mural).toEqual({ enabled: true, model: "provider/cue-model" });
121
+ expect((result as Record<string, unknown>).experimental).toBeUndefined();
122
+ expect(
123
+ result.configWarnings?.some((warning) =>
124
+ warning.includes('Deprecated "experimental.mural"; use top-level "mural" instead'),
125
+ ),
126
+ ).toBe(true);
127
+ });
128
+
129
+ it("keeps the top-level mural values when both spellings exist", () => {
130
+ const result = loadWithUserConfig(
131
+ JSON.stringify({
132
+ mural: { enabled: false, model: "provider/new-model" },
133
+ experimental: { mural: { enabled: true, model: "provider/old-model" } },
134
+ }),
135
+ );
136
+
137
+ expect(result.mural).toEqual({ enabled: false, model: "provider/new-model" });
138
+ expect(result.configWarnings ?? []).not.toContain(
139
+ expect.stringContaining('Deprecated "experimental.mural"'),
140
+ );
141
+ });
142
+ });
143
+
144
+ describe("loadPluginConfig — transform mode resolution", () => {
145
+ it("downgrades rust when compaction is off and emits one boot warning", () => {
146
+ const result = loadWithUserConfig(
147
+ JSON.stringify({
148
+ compaction: { enabled: false },
149
+ transform_mode: "rust",
150
+ subc: { connection_file: "/tmp/subc.sock" },
151
+ }),
152
+ );
153
+
154
+ expect(result.transform_mode).toBe("ts");
155
+ expect(result.configWarnings?.filter((warning) => warning.includes("rust"))).toEqual([
156
+ `[config] ${RUST_COMPACTION_OFF_WARNING}`,
157
+ ]);
158
+ });
159
+
160
+ it("keeps rust when compaction is on", () => {
161
+ const result = loadWithUserConfig(
162
+ JSON.stringify({
163
+ compaction: { enabled: true },
164
+ transform_mode: "rust",
165
+ subc: { connection_file: "/tmp/subc.sock" },
166
+ }),
167
+ );
168
+
169
+ expect(result.transform_mode).toBe("rust");
170
+ expect(result.configWarnings ?? []).not.toContain(
171
+ expect.stringContaining(RUST_COMPACTION_OFF_WARNING),
172
+ );
173
+ });
174
+ });
175
+
176
+ describe("loadPluginConfig — secret redaction", () => {
177
+ it("reads an unmigrated legacy project config instead of falling to defaults", () => {
178
+ const xdg = mkdtempSync(join(tmpdir(), "mc-config-test-"));
179
+ const home = mkdtempSync(join(tmpdir(), "mc-config-home-"));
180
+ const projectDir = mkdtempSync(join(tmpdir(), "mc-config-legacy-proj-"));
181
+ const origXdg = process.env.XDG_CONFIG_HOME;
182
+ const origHome = process.env.HOME;
183
+ process.env.XDG_CONFIG_HOME = xdg;
184
+ process.env.HOME = home;
185
+ // A real setting the user disabled: it MUST survive the unmigrated window,
186
+ // not be silently re-enabled by schema defaults (memory defaults to on).
187
+ writeFileSync(
188
+ join(projectDir, "magic-context.jsonc"),
189
+ '{"embedding":{"provider":"off"},"memory":{"enabled":false}}',
190
+ );
191
+ try {
192
+ const result = loadPluginConfigDetailed(projectDir);
193
+
194
+ // Read-legacy-on-conflict: the legacy file is the source of truth
195
+ // until migration completes, so the load is trusted ("ok") and the
196
+ // real (disabled) setting is honored, not silently defaulted on.
197
+ // (embedding.* is intentionally stripped from project-scope config for
198
+ // security, so we assert a non-embedding setting survives.)
199
+ expect(result.sources.projectConfig).toBe("ok");
200
+ expect(result.loadOutcome).toBe("ok");
201
+ expect(result.config.memory.enabled).toBe(false);
202
+ expect(result.config.configWarnings?.join("\n")).toContain(
203
+ "reading legacy config from",
204
+ );
205
+ } finally {
206
+ if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME;
207
+ else process.env.XDG_CONFIG_HOME = origXdg;
208
+ if (origHome === undefined) delete process.env.HOME;
209
+ else process.env.HOME = origHome;
210
+ rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
211
+ rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
212
+ rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
213
+ }
214
+ });
215
+
216
+ it("loadPluginConfig (the runtime init path) honors read-legacy, not schema defaults", () => {
217
+ // The runtime registers via loadPluginConfig (index.ts), NOT the detailed
218
+ // variant. This locks that the read-legacy fallback applies there too — a
219
+ // migration refusal must not silently re-enable disabled features at init.
220
+ const xdg = mkdtempSync(join(tmpdir(), "mc-config-test-"));
221
+ const home = mkdtempSync(join(tmpdir(), "mc-config-home-"));
222
+ const projectDir = mkdtempSync(join(tmpdir(), "mc-config-legacy-proj-"));
223
+ const origXdg = process.env.XDG_CONFIG_HOME;
224
+ const origHome = process.env.HOME;
225
+ process.env.XDG_CONFIG_HOME = xdg;
226
+ process.env.HOME = home;
227
+ writeFileSync(join(projectDir, "magic-context.jsonc"), '{"memory":{"enabled":false}}');
228
+ try {
229
+ const config = loadPluginConfig(projectDir);
230
+ expect(config.memory.enabled).toBe(false);
231
+ expect(config.configWarnings?.join("\n")).toContain("reading legacy config from");
232
+ } finally {
233
+ if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME;
234
+ else process.env.XDG_CONFIG_HOME = origXdg;
235
+ if (origHome === undefined) delete process.env.HOME;
236
+ else process.env.HOME = origHome;
237
+ rmSync(xdg, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
238
+ rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
239
+ rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
240
+ }
241
+ });
242
+
243
+ it("does NOT leak resolved env values through Zod validation warnings", () => {
244
+ const secret = "sk-live-CARDINAL-SIN-IF-THIS-APPEARS-IN-LOGS";
245
+ const config = JSON.stringify({
246
+ // `historian_timeout_ms` has a minimum of 60_000. Feeding the
247
+ // substituted secret string here causes Zod to reject the field
248
+ // and route through the warning path we care about.
249
+ historian_timeout_ms: "{env:MC_TEST_SECRET}",
250
+ });
251
+
252
+ const result = loadWithUserConfig(config, { MC_TEST_SECRET: secret });
253
+ const warnings = result.configWarnings ?? [];
254
+
255
+ // The plugin should still load (enabled: true kept by recovery path).
256
+ expect(result.enabled).toBe(true);
257
+
258
+ // No warning or config field may contain the resolved secret.
259
+ const allText = JSON.stringify({ config: result, warnings });
260
+ expect(allText).not.toContain(secret);
261
+ expect(allText).not.toContain("CARDINAL-SIN");
262
+
263
+ // But the warnings should still describe what failed. We expect a
264
+ // warning mentioning historian_timeout_ms and the safe type summary.
265
+ const relevantWarning = warnings.find((w) => w.includes("historian_timeout_ms"));
266
+ expect(relevantWarning).toBeDefined();
267
+ expect(relevantWarning).toContain("invalid value");
268
+ // Must show type + length, not the value itself.
269
+ expect(relevantWarning).toMatch(/string, \d+ chars?/);
270
+ });
271
+
272
+ it("redacts long string values of any source (not just env-substituted)", () => {
273
+ // Verifies the redaction applies to plain invalid values too — we
274
+ // don't want to special-case env vs non-env because we can't tell
275
+ // them apart at the Zod layer.
276
+ const config = JSON.stringify({
277
+ historian_timeout_ms: "super-secret-plain-literal-that-should-not-leak",
278
+ });
279
+
280
+ const result = loadWithUserConfig(config);
281
+ const warnings = result.configWarnings ?? [];
282
+ const combined = warnings.join("\n");
283
+
284
+ expect(combined).not.toContain("super-secret-plain-literal-that-should-not-leak");
285
+ expect(combined).toMatch(/string, \d+ chars?/);
286
+ });
287
+
288
+ it("redacts nested object values to structural shape only", () => {
289
+ const config = JSON.stringify({
290
+ historian_timeout_ms: { nested: "secret-xyz", apiKey: "also-secret" },
291
+ });
292
+
293
+ const result = loadWithUserConfig(config);
294
+ const warnings = result.configWarnings ?? [];
295
+ const combined = warnings.join("\n");
296
+
297
+ expect(combined).not.toContain("secret-xyz");
298
+ expect(combined).not.toContain("also-secret");
299
+ expect(combined).toContain("object with keys");
300
+ expect(combined).toContain("nested");
301
+ expect(combined).toContain("apiKey");
302
+ });
303
+
304
+ it("preserves dreamer.enabled=false migration after nested-field recovery", () => {
305
+ const config = JSON.stringify({
306
+ dreamer: { enabled: false },
307
+ memory: { injection_budget_tokens: "not-a-number" },
308
+ });
309
+
310
+ const result = loadWithUserConfig(config);
311
+
312
+ expect(result.dreamer?.disable).toBe(true);
313
+ expect(result.configWarnings?.join("\n")).toContain("dreamer.enabled=false");
314
+ });
315
+
316
+ it("recovers an invalid NESTED field without wiping valid siblings in the same block", () => {
317
+ // Regression: one bad nested field (memory.injection_budget_tokens as a
318
+ // string) must NOT delete the whole `memory` block — which would silently
319
+ // drop valid siblings like memory.auto_search.enabled (and, on the
320
+ // migration path, the just-graduated memory.git_commit_indexing). Recovery
321
+ // should prune only the invalid leaf and keep the rest.
322
+ const config = JSON.stringify({
323
+ memory: {
324
+ injection_budget_tokens: "not-a-number", // invalid nested leaf
325
+ auto_search: { enabled: false }, // valid sibling — must survive
326
+ },
327
+ });
328
+
329
+ const result = loadWithUserConfig(config);
330
+ const warnings = result.configWarnings ?? [];
331
+
332
+ expect(result.enabled).toBe(true);
333
+ // The valid sibling the user explicitly set must be preserved, not reset
334
+ // to the schema default (true).
335
+ expect(result.memory.auto_search.enabled).toBe(false);
336
+ // The invalid leaf falls back to its schema default.
337
+ expect(typeof result.memory.injection_budget_tokens).toBe("number");
338
+ // A warning should name the pruned nested field.
339
+ const w = warnings.find(
340
+ (x) => x.includes("memory") && x.includes("injection_budget_tokens"),
341
+ );
342
+ expect(w).toBeDefined();
343
+ });
344
+
345
+ it("still shows numeric and boolean invalid values (not secrets by nature)", () => {
346
+ // Numbers/booleans in config fields are never secrets — they're
347
+ // plain validation mistakes — so we surface them fully to help
348
+ // the user diagnose.
349
+ const config = JSON.stringify({
350
+ execute_threshold_percentage: 5, // below min (20)
351
+ });
352
+
353
+ const result = loadWithUserConfig(config);
354
+ const warnings = result.configWarnings ?? [];
355
+ const combined = warnings.join("\n");
356
+
357
+ expect(combined).toContain("execute_threshold_percentage");
358
+ // `number 5` is the human-friendly safe render.
359
+ expect(combined).toMatch(/number 5/);
360
+ });
361
+
362
+ it("rejects execute_threshold_percentage > 90 with the cache-safety explanation (issue #111)", () => {
363
+ const config = JSON.stringify({
364
+ execute_threshold_percentage: 91, // above cap (90)
365
+ });
366
+
367
+ const result = loadWithUserConfig(config);
368
+ const warnings = result.configWarnings ?? [];
369
+ const combined = warnings.join("\n");
370
+
371
+ expect(combined).toContain("execute_threshold_percentage");
372
+ // The custom message explains WHY, not just "too big".
373
+ expect(combined).toContain("capped at 90% for cache safety");
374
+ });
375
+ it("keeps embedding destination fields from trusted user config", () => {
376
+ const config = JSON.stringify({
377
+ embedding: {
378
+ provider: "openai-compatible",
379
+ endpoint: "https://embeddings.example/v1",
380
+ model: "text-embedding-3-small",
381
+ },
382
+ });
383
+
384
+ const result = loadWithUserConfig(config);
385
+
386
+ expect(result.embedding.provider).toBe("openai-compatible");
387
+ expect(result.embedding.endpoint).toBe("https://embeddings.example/v1");
388
+ });
389
+
390
+ it("honors user storage permissions while ignoring a project-tier override", () => {
391
+ const result = loadWithUserAndProjectConfig(
392
+ JSON.stringify({ storage: { enforce_private_permissions: false } }),
393
+ JSON.stringify({ storage: { enforce_private_permissions: true, futureSibling: 1 } }),
394
+ );
395
+
396
+ expect(result.storage.enforce_private_permissions).toBe(false);
397
+ expect(result.configWarnings?.join("\n")).toContain("storage.enforce_private_permissions");
398
+ });
399
+
400
+ it("ignores embedding destination fields from untrusted project config", () => {
401
+ const userConfig = JSON.stringify({
402
+ embedding: {
403
+ provider: "openai-compatible",
404
+ endpoint: "https://trusted.example/v1",
405
+ model: "trusted-model",
406
+ },
407
+ });
408
+ const projectConfig = JSON.stringify({
409
+ embedding: {
410
+ provider: "openai-compatible",
411
+ endpoint: "https://evil.example/v1",
412
+ model: "repo-model",
413
+ },
414
+ });
415
+
416
+ const result = loadWithUserAndProjectConfig(userConfig, projectConfig);
417
+
418
+ expect(result.embedding.provider).toBe("openai-compatible");
419
+ expect(result.embedding.endpoint).toBe("https://trusted.example/v1");
420
+ expect(result.embedding.model).toBe("repo-model");
421
+ expect(result.configWarnings?.join("\n")).toContain("embedding.endpoint/provider");
422
+ });
423
+ });
424
+
425
+ describe("loadPluginConfig — experimental graduation migration", () => {
426
+ // These cover the FULL chain: experimental.* → (migrate-experimental) →
427
+ // legacy dreamer.user_memories/pin_key_files → (migrate-dreamer-v2) → the v2
428
+ // per-task `dreamer.tasks` record. review-user-memories enabled ⇔ schedule != "";
429
+ // key-files likewise.
430
+ it("migrates experimental.user_memories object block to a scheduled review-user-memories task", () => {
431
+ const config = JSON.stringify({
432
+ experimental: {
433
+ user_memories: {
434
+ enabled: true,
435
+ promotion_threshold: 5,
436
+ },
437
+ },
438
+ });
439
+
440
+ const result = loadWithUserConfig(config);
441
+ const rum = result.dreamer?.tasks["review-user-memories"];
442
+ expect(rum?.schedule).not.toBe("");
443
+ expect(rum?.promotion_threshold).toBe(5);
444
+ // Warning so users know to run doctor.
445
+ expect(result.configWarnings?.join("\n")).toContain("experimental.user_memories");
446
+ });
447
+
448
+ it("coerces primitive experimental.user_memories: false to a disabled review task", () => {
449
+ // Without the chain, the user's opt-out would silently flip to the new
450
+ // default (enabled). The disabled opt-out must survive as schedule "".
451
+ const config = JSON.stringify({
452
+ experimental: {
453
+ user_memories: false,
454
+ },
455
+ });
456
+
457
+ const result = loadWithUserConfig(config);
458
+ expect(result.dreamer?.tasks["review-user-memories"].schedule).toBe("");
459
+ });
460
+
461
+ it("drops legacy experimental.pin_key_files — no key-files task is emitted", () => {
462
+ // key-files was removed (feature moved to AFT's dreamer). A legacy
463
+ // experimental.pin_key_files block migrates forward but produces no task.
464
+ const config = JSON.stringify({
465
+ experimental: {
466
+ pin_key_files: { enabled: true, token_budget: 9000, min_reads: 5 },
467
+ },
468
+ });
469
+
470
+ const result = loadWithUserConfig(config);
471
+ expect("key-files" in (result.dreamer?.tasks ?? {})).toBe(false);
472
+ });
473
+
474
+ it("preserves an explicit promotion_threshold through the v2 migration", () => {
475
+ const config = JSON.stringify({
476
+ experimental: {
477
+ user_memories: {
478
+ enabled: false,
479
+ promotion_threshold: 10,
480
+ },
481
+ },
482
+ });
483
+
484
+ const result = loadWithUserConfig(config);
485
+ const rum = result.dreamer?.tasks["review-user-memories"];
486
+ // enabled:false → disabled task, but the threshold is carried.
487
+ expect(rum?.schedule).toBe("");
488
+ expect(rum?.promotion_threshold).toBe(10);
489
+ });
490
+
491
+ it("is a no-op when no experimental block exists", () => {
492
+ const config = JSON.stringify({ enabled: true });
493
+ const result = loadWithUserConfig(config);
494
+ // No warning, no disruption.
495
+ expect(result.configWarnings).toBeUndefined();
496
+ });
497
+
498
+ it("temporal_awareness and memory.auto_search default ON; git_commit_indexing and caveman default OFF", () => {
499
+ const result = loadWithUserConfig(JSON.stringify({ enabled: true }));
500
+ expect(result.temporal_awareness).toBe(true);
501
+ expect(result.memory.auto_search.enabled).toBe(true);
502
+ expect(result.memory.git_commit_indexing.enabled).toBe(false);
503
+ expect(result.caveman_text_compression.enabled).toBe(false);
504
+ });
505
+
506
+ it("relocates legacy experimental.* graduated keys to top-level + memory.* (run-doctor warning)", () => {
507
+ const config = JSON.stringify({
508
+ experimental: {
509
+ temporal_awareness: false,
510
+ auto_search: { enabled: false },
511
+ git_commit_indexing: { enabled: true, since_days: 30 },
512
+ caveman_text_compression: { enabled: true, min_chars: 800 },
513
+ },
514
+ });
515
+ const result = loadWithUserConfig(config);
516
+ // Explicit user values survive the relocation (opt-outs/opt-ins preserved).
517
+ expect(result.temporal_awareness).toBe(false);
518
+ expect(result.caveman_text_compression.enabled).toBe(true);
519
+ expect(result.caveman_text_compression.min_chars).toBe(800);
520
+ // auto_search + git_commit_indexing land under memory.*
521
+ expect(result.memory.auto_search.enabled).toBe(false);
522
+ expect(result.memory.git_commit_indexing.enabled).toBe(true);
523
+ expect(result.memory.git_commit_indexing.since_days).toBe(30);
524
+ const warnings = result.configWarnings?.join("\n") ?? "";
525
+ expect(warnings).toContain("experimental.temporal_awareness");
526
+ expect(warnings).toContain('"memory.auto_search"');
527
+ expect(warnings).toContain('"memory.git_commit_indexing"');
528
+ });
529
+
530
+ it("memory.* graduated key wins over a legacy experimental.* duplicate (sub-fields merge)", () => {
531
+ const config = JSON.stringify({
532
+ experimental: {
533
+ git_commit_indexing: { enabled: false, since_days: 99, max_commits: 500 },
534
+ },
535
+ memory: { git_commit_indexing: { enabled: true } },
536
+ });
537
+ const result = loadWithUserConfig(config);
538
+ // memory.* (graduated) enabled wins; missing sub-fields fill from old block.
539
+ expect(result.memory.git_commit_indexing.enabled).toBe(true);
540
+ expect(result.memory.git_commit_indexing.since_days).toBe(99);
541
+ expect(result.memory.git_commit_indexing.max_commits).toBe(500);
542
+ });
543
+ });
544
+
545
+ describe("loadPluginConfig — legacy agent enabled migration", () => {
546
+ it("migrates dreamer.enabled=false to disable=true with manual-dream warning", () => {
547
+ const result = loadWithUserConfig(JSON.stringify({ dreamer: { enabled: false } }));
548
+
549
+ expect(result.dreamer?.disable).toBe(true);
550
+ expect(result.configWarnings?.join("\n")).toContain(
551
+ 'Migrated "dreamer.enabled=false" → "dreamer.disable=true" in-memory (run doctor to persist). This now also disables manual /ctx-dream; for manual-only remove disable and set schedule="".',
552
+ );
553
+ });
554
+
555
+ it("removes dreamer.enabled=true silently (no warning, no disable mutation)", () => {
556
+ const result = loadWithUserConfig(JSON.stringify({ dreamer: { enabled: true } }));
557
+
558
+ expect(result.dreamer?.disable).toBeUndefined();
559
+ expect("enabled" in (result.dreamer as Record<string, unknown>)).toBe(false);
560
+ // enabled=true is a no-op alias for the new default; no warning should be emitted.
561
+ const warnings = result.configWarnings?.join("\n") ?? "";
562
+ expect(warnings).not.toContain("dreamer.enabled=true");
563
+ expect(warnings).not.toContain("dreamer.enabled");
564
+ });
565
+
566
+ it("migrates sidekick.enabled=false (loud) and removes sidekick.enabled=true (silent)", () => {
567
+ const disabled = loadWithUserConfig(JSON.stringify({ sidekick: { enabled: false } }));
568
+ expect(disabled.sidekick?.disable).toBe(true);
569
+ expect(disabled.configWarnings?.join("\n")).toContain(
570
+ 'Migrated "sidekick.enabled=false" → "sidekick.disable=true" in-memory (run doctor to persist).',
571
+ );
572
+
573
+ const enabled = loadWithUserConfig(JSON.stringify({ sidekick: { enabled: true } }));
574
+ expect(enabled.sidekick?.disable).toBeUndefined();
575
+ expect("enabled" in (enabled.sidekick as Record<string, unknown>)).toBe(false);
576
+ const enabledWarnings = enabled.configWarnings?.join("\n") ?? "";
577
+ expect(enabledWarnings).not.toContain("sidekick.enabled");
578
+ });
579
+
580
+ it("removes invalid historian.enabled and applies conflict rules", () => {
581
+ const result = loadWithUserConfig(
582
+ JSON.stringify({
583
+ historian: { enabled: false },
584
+ dreamer: { enabled: false, disable: false },
585
+ sidekick: { enabled: true, disable: true },
586
+ }),
587
+ );
588
+
589
+ expect(result.historian).toEqual({ two_pass: false, disallowed_tools: [] });
590
+ expect(result.dreamer?.disable).toBe(true);
591
+ expect(result.sidekick?.disable).toBe(true);
592
+ expect(result.configWarnings?.join("\n")).toContain(
593
+ 'Removed invalid "historian.enabled" in-memory (run doctor to persist).',
594
+ );
595
+ });
596
+ });
597
+
598
+ describe("loadPluginConfig — variable expansion scope", () => {
599
+ it("keeps {env:} and {file:} expansion enabled for user config", () => {
600
+ const secretFile = join(mkdtempSync(join(tmpdir(), "mc-config-secret-")), "secret.txt");
601
+ writeFileSync(secretFile, "file-secret", "utf-8");
602
+
603
+ try {
604
+ const result = loadWithUserConfig(
605
+ JSON.stringify({
606
+ embedding: {
607
+ provider: "openai-compatible",
608
+ model: `{file:${secretFile}}`,
609
+ endpoint: "{env:MC_USER_ENDPOINT}",
610
+ },
611
+ }),
612
+ { MC_USER_ENDPOINT: "http://user-env.test/v1" },
613
+ );
614
+
615
+ expect(result.embedding.provider).toBe("openai-compatible");
616
+ if (result.embedding.provider === "openai-compatible") {
617
+ expect(result.embedding.model).toBe("file-secret");
618
+ expect(result.embedding.endpoint).toBe("http://user-env.test/v1");
619
+ }
620
+ expect(result.configWarnings).toBeUndefined();
621
+ } finally {
622
+ rmSync(secretFile, { force: true });
623
+ }
624
+ });
625
+
626
+ it("leaves {env:} and {file:} tokens literal in project config and warns", () => {
627
+ const secretFile = join(mkdtempSync(join(tmpdir(), "mc-config-secret-")), "secret.txt");
628
+ writeFileSync(secretFile, "project-file-secret", "utf-8");
629
+
630
+ try {
631
+ const result = loadWithUserAndProjectConfig(
632
+ JSON.stringify({ enabled: true }),
633
+ JSON.stringify({
634
+ embedding: {
635
+ provider: "openai-compatible",
636
+ model: `{file:${secretFile}}`,
637
+ endpoint: "{env:MC_PROJECT_ENDPOINT}",
638
+ },
639
+ }),
640
+ { MC_PROJECT_ENDPOINT: "http://project-env.test/v1" },
641
+ );
642
+
643
+ expect(result.embedding.provider).toBe("local");
644
+ expect(result.embedding.model).toBe(`{file:${secretFile}}`);
645
+ const warnings = result.configWarnings?.join("\n") ?? "";
646
+ expect(warnings).toContain("Project-level config no longer supports");
647
+ expect(warnings).toContain("security reasons");
648
+ expect(warnings).toContain("embedding.endpoint/provider");
649
+ } finally {
650
+ rmSync(secretFile, { force: true });
651
+ }
652
+ });
653
+
654
+ it("prevents project literal endpoint tokens from overriding user-expanded destinations", () => {
655
+ const result = loadWithUserAndProjectConfig(
656
+ JSON.stringify({
657
+ embedding: {
658
+ provider: "openai-compatible",
659
+ model: "user-model",
660
+ endpoint: "{env:MC_USER_ENDPOINT}",
661
+ },
662
+ }),
663
+ JSON.stringify({
664
+ embedding: {
665
+ endpoint: "{env:MC_PROJECT_LITERAL}",
666
+ },
667
+ }),
668
+ {
669
+ MC_USER_ENDPOINT: "http://user-expanded.test/v1",
670
+ MC_PROJECT_LITERAL: "http://should-not-expand.test/v1",
671
+ },
672
+ );
673
+
674
+ expect(result.embedding.provider).toBe("openai-compatible");
675
+ if (result.embedding.provider === "openai-compatible") {
676
+ expect(result.embedding.model).toBe("user-model");
677
+ expect(result.embedding.endpoint).toBe("http://user-expanded.test/v1");
678
+ }
679
+ });
680
+ });
681
+
682
+ describe("loadPluginConfig — user-only settings", () => {
683
+ it("allows user config to disable auto_update", () => {
684
+ const result = loadWithUserConfig(JSON.stringify({ auto_update: false }));
685
+
686
+ expect(result.auto_update).toBe(false);
687
+ });
688
+
689
+ it("allows user config to opt in to an exact home project", () => {
690
+ const result = loadWithUserConfig(JSON.stringify({ allow_home_project: true }));
691
+
692
+ expect(result.allow_home_project).toBe(true);
693
+ });
694
+
695
+ it("prevents project config from opting in to a home project", () => {
696
+ const result = loadWithUserAndProjectConfig(
697
+ JSON.stringify({ allow_home_project: false }),
698
+ JSON.stringify({ allow_home_project: true }),
699
+ );
700
+
701
+ expect(result.allow_home_project).toBe(false);
702
+ expect(result.configWarnings?.join("\n")).toContain("Ignoring allow_home_project");
703
+ });
704
+
705
+ it("prevents project config from overriding user auto_update", () => {
706
+ const result = loadWithUserAndProjectConfig(
707
+ JSON.stringify({ auto_update: true, enabled: true }),
708
+ JSON.stringify({ auto_update: false, enabled: false }),
709
+ );
710
+
711
+ expect(result.auto_update).toBe(true);
712
+ expect(result.enabled).toBe(false);
713
+ expect(result.configWarnings?.join("\n")).toContain("Ignoring auto_update");
714
+ });
715
+
716
+ it("keeps historian model selection user-owned when project config tries to override it", () => {
717
+ const result = loadWithUserAndProjectConfig(
718
+ JSON.stringify({
719
+ historian: {
720
+ model: "anthropic/user-historian",
721
+ fallback_models: ["anthropic/user-fallback"],
722
+ },
723
+ }),
724
+ JSON.stringify({
725
+ historian: {
726
+ model: "anthropic/project-historian",
727
+ fallback_models: ["anthropic/project-fallback"],
728
+ temperature: 0.2,
729
+ },
730
+ }),
731
+ );
732
+
733
+ expect(result.historian?.model).toBe("anthropic/user-historian");
734
+ expect(result.historian?.fallback_models).toEqual(["anthropic/user-fallback"]);
735
+ expect(result.historian?.temperature).toBe(0.2);
736
+ expect(result.configWarnings?.join("\n")).toContain(
737
+ "Ignoring historian.model/fallback_models",
738
+ );
739
+ });
740
+ });
741
+
742
+ describe("loadPluginConfig — project compaction trust boundary", () => {
743
+ it("ignores a lower project execute_threshold_percentage with a warning", () => {
744
+ const result = loadWithUserAndProjectConfig(
745
+ JSON.stringify({ execute_threshold_percentage: 60 }),
746
+ JSON.stringify({ execute_threshold_percentage: 50 }),
747
+ );
748
+
749
+ expect(result.execute_threshold_percentage).toBe(60);
750
+ expect(result.configWarnings?.join("\n")).toContain(
751
+ "Ignoring execute_threshold_percentage",
752
+ );
753
+ });
754
+
755
+ it("applies a higher project execute_threshold_percentage", () => {
756
+ const result = loadWithUserAndProjectConfig(
757
+ JSON.stringify({ execute_threshold_percentage: 60 }),
758
+ JSON.stringify({ execute_threshold_percentage: 70 }),
759
+ );
760
+
761
+ expect(result.execute_threshold_percentage).toBe(70);
762
+ expect(result.configWarnings?.join("\n") ?? "").not.toContain(
763
+ "execute_threshold_percentage",
764
+ );
765
+ });
766
+
767
+ it("ignores a lower project execute_threshold_tokens.default with a warning", () => {
768
+ const result = loadWithUserAndProjectConfig(
769
+ JSON.stringify({ execute_threshold_tokens: { default: 12_000 } }),
770
+ JSON.stringify({ execute_threshold_tokens: { default: 9_000 } }),
771
+ );
772
+
773
+ expect(result.execute_threshold_tokens).toEqual({ default: 12_000 });
774
+ expect(result.configWarnings?.join("\n")).toContain(
775
+ "Ignoring execute_threshold_tokens.default",
776
+ );
777
+ });
778
+
779
+ it("applies a higher project execute_threshold_tokens.default", () => {
780
+ const result = loadWithUserAndProjectConfig(
781
+ JSON.stringify({ execute_threshold_tokens: { default: 12_000 } }),
782
+ JSON.stringify({ execute_threshold_tokens: { default: 18_000 } }),
783
+ );
784
+
785
+ expect(result.execute_threshold_tokens).toEqual({ default: 18_000 });
786
+ });
787
+ });
788
+
789
+ describe("loadPluginConfig — raw merge preserves user fields not set in project", () => {
790
+ // Regression for the 2026-05-12 embedding-wipe bug. Project configs that
791
+ // don't mention `embedding` (or any other defaulted field) must inherit
792
+ // the user's explicit value instead of getting clobbered by the Zod
793
+ // default. Previously each source was parsed separately and Zod-filled
794
+ // defaults appeared as if they were explicit project overrides.
795
+
796
+ it("user embedding survives when project config omits embedding", () => {
797
+ const userConfig = JSON.stringify({
798
+ embedding: {
799
+ provider: "openai-compatible",
800
+ model: "text-embedding-qwen3-embedding-8b",
801
+ endpoint: "http://localhost:1234/v1",
802
+ },
803
+ });
804
+ const projectConfig = JSON.stringify({ smart_drops: true });
805
+
806
+ const result = loadWithUserAndProjectConfig(userConfig, projectConfig);
807
+
808
+ expect(result.embedding.provider).toBe("openai-compatible");
809
+ if (result.embedding.provider === "openai-compatible") {
810
+ expect(result.embedding.model).toBe("text-embedding-qwen3-embedding-8b");
811
+ expect(result.embedding.endpoint).toBe("http://localhost:1234/v1");
812
+ }
813
+ });
814
+
815
+ it("project can still tune embedding model without changing the destination", () => {
816
+ const userConfig = JSON.stringify({
817
+ embedding: {
818
+ provider: "openai-compatible",
819
+ model: "user-model",
820
+ endpoint: "http://user:1/v1",
821
+ },
822
+ });
823
+ const projectConfig = JSON.stringify({
824
+ embedding: {
825
+ provider: "openai-compatible",
826
+ model: "project-model",
827
+ endpoint: "http://project:1/v1",
828
+ },
829
+ });
830
+
831
+ const result = loadWithUserAndProjectConfig(userConfig, projectConfig);
832
+ expect(result.embedding.provider).toBe("openai-compatible");
833
+ if (result.embedding.provider === "openai-compatible") {
834
+ expect(result.embedding.model).toBe("project-model");
835
+ expect(result.embedding.endpoint).toBe("http://user:1/v1");
836
+ }
837
+ expect(result.configWarnings?.join("\n")).toContain("embedding.endpoint/provider");
838
+ });
839
+
840
+ it("user scalar field survives when project omits it", () => {
841
+ // execute_threshold_percentage default is { default: 65, ... }. User
842
+ // sets a value, project doesn't mention it — user must win.
843
+ const result = loadWithUserAndProjectConfig(
844
+ JSON.stringify({ execute_threshold_percentage: 30, enabled: true }),
845
+ JSON.stringify({ smart_drops: false }),
846
+ );
847
+
848
+ // execute_threshold_percentage min is 20, so 30 is valid
849
+ expect(result.execute_threshold_percentage).toBe(30);
850
+ });
851
+
852
+ it("still applies project dreamer model and task overrides", () => {
853
+ const result = loadWithUserAndProjectConfig(
854
+ JSON.stringify({ language: "tr" }),
855
+ JSON.stringify({
856
+ dreamer: {
857
+ model: "anthropic/project-dreamer",
858
+ tasks: {
859
+ verify: {
860
+ schedule: "0 3 * * *",
861
+ model: "anthropic/project-verify",
862
+ },
863
+ },
864
+ },
865
+ }),
866
+ );
867
+
868
+ expect(result.language).toBe("tr");
869
+ expect(result.dreamer?.model).toBe("anthropic/project-dreamer");
870
+ expect(result.dreamer?.tasks.verify.schedule).toBe("0 3 * * *");
871
+ expect(result.dreamer?.tasks.verify.model).toBe("anthropic/project-verify");
872
+ });
873
+
874
+ it("project boolean override beats user default", () => {
875
+ // User omits smart_drops (default false). Project turns it on.
876
+ const result = loadWithUserAndProjectConfig(
877
+ JSON.stringify({ enabled: true }),
878
+ JSON.stringify({ smart_drops: true }),
879
+ );
880
+
881
+ expect(result.smart_drops).toBe(true);
882
+ });
883
+
884
+ it("ignores the removed ctx_reduce_enabled key without failing parse", () => {
885
+ const result = loadWithUserAndProjectConfig(
886
+ JSON.stringify({ ctx_reduce_enabled: false, execute_threshold_percentage: 30 }),
887
+ JSON.stringify({}),
888
+ );
889
+
890
+ expect(result.execute_threshold_percentage).toBe(30);
891
+ expect("ctx_reduce_enabled" in result).toBe(false);
892
+ });
893
+
894
+ it("disabled_hooks union-merges across user and project", () => {
895
+ const result = loadWithUserAndProjectConfig(
896
+ JSON.stringify({ disabled_hooks: ["a", "b"] }),
897
+ JSON.stringify({ disabled_hooks: ["b", "c"] }),
898
+ );
899
+
900
+ expect(result.disabled_hooks?.sort()).toEqual(["a", "b", "c"]);
901
+ });
902
+ });
903
+
904
+ describe("transform_mode resolution", () => {
905
+ it("keeps project rust mode only when user config supplies subc", () => {
906
+ const withSubc = loadWithUserAndProjectConfig(
907
+ JSON.stringify({ subc: { connection_file: "~/.local/share/cortexkit/subc.json" } }),
908
+ JSON.stringify({ transform_mode: "rust" }),
909
+ );
910
+ expect(withSubc.transform_mode).toBe("rust");
911
+
912
+ const withoutSubc = loadWithUserAndProjectConfig(
913
+ JSON.stringify({}),
914
+ JSON.stringify({ transform_mode: "rust" }),
915
+ );
916
+ expect(withoutSubc.transform_mode).toBe("ts");
917
+ expect(withoutSubc.configWarnings?.join("\n")).toContain(
918
+ "rust mode requires user-level subc configuration; running ts.",
919
+ );
920
+ });
921
+
922
+ it("passes the resolved rust mode to the plugin config without mutating project trust", () => {
923
+ const result = loadWithUserAndProjectConfig(
924
+ JSON.stringify({
925
+ subc: { connection_file: "~/.local/share/cortexkit/subc.json" },
926
+ }),
927
+ JSON.stringify({
928
+ transform_mode: "rust",
929
+ subc: { connection_file: "/tmp/project-controlled.sock" },
930
+ }),
931
+ );
932
+
933
+ expect(result.transform_mode).toBe("rust");
934
+ expect(result.subc?.connection_file).not.toContain("project-controlled.sock");
935
+ });
936
+ });