@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,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
 
3
3
  import { detectConfigFile, isPrototypePollutionKey, parseJsonc } from "../shared/jsonc-parser";
4
4
  import { setOutputReserveConfig } from "../shared/models-dev-cache";
@@ -14,12 +14,14 @@ import {
14
14
  } from "./migrate-config-location";
15
15
  import { migrateDreamerV2 } from "./migrate-dreamer-v2";
16
16
  import { migrateLegacyExperimental } from "./migrate-experimental";
17
+ import { resolveConfigProfile } from "./profiles";
17
18
  import {
18
19
  constrainProjectThresholdOverrides,
19
20
  dropInheritedEmbeddingKeyOnRedirect,
20
21
  stripUnsafeProjectConfigFields,
21
22
  } from "./project-security";
22
23
  import { pruneNestedConfigLeaf } from "./prune-config-leaf";
24
+ import { loadRawConfigFile } from "./raw-loader";
23
25
  import { type MagicContextConfig, MagicContextConfigSchema } from "./schema/magic-context";
24
26
  import { resolveTransformMode } from "./transform-mode";
25
27
  import { substituteConfigVariables } from "./variable";
@@ -109,8 +111,12 @@ function loadConfigFileDetailed(
109
111
  }
110
112
 
111
113
  let rawText: string;
114
+ let rawWarnings: string[];
112
115
  try {
113
- rawText = readFileSync(configPath, "utf-8");
116
+ const raw = loadRawConfigFile({ configPath, tier: source });
117
+ if (!raw) return null;
118
+ rawText = raw.text;
119
+ rawWarnings = raw.warnings;
114
120
  } catch (error) {
115
121
  return {
116
122
  config: {},
@@ -138,7 +144,7 @@ function loadConfigFileDetailed(
138
144
  );
139
145
  return {
140
146
  config,
141
- warnings: [...substituted.warnings, ...unsafeKeyWarnings].map(
147
+ warnings: [...rawWarnings, ...substituted.warnings, ...unsafeKeyWarnings].map(
142
148
  (warning) => `${configPath}: ${warning}`,
143
149
  ),
144
150
  outcome:
@@ -285,8 +291,9 @@ function parsePluginConfig(
285
291
  }
286
292
 
287
293
  // Full parse failed — recover field-by-field using defaults for invalid fields.
288
- // Agent configs (historian, dreamer, sidekick) are dropped on error rather than defaulted
289
- // because wrong model config could run expensive models or fail silently.
294
+ // Invalid nested leaves are pruned from agent blocks; only root-level or
295
+ // unreachable agent errors drop the block, because guessing a model config
296
+ // could run an expensive unintended model or fail silently.
290
297
  const defaults = MagicContextConfigSchema.parse({});
291
298
  const warnings: string[] = [];
292
299
 
@@ -309,7 +316,13 @@ function parsePluginConfig(
309
316
  const key = String(topKey);
310
317
  errorPaths.add(key);
311
318
  const paths = issuePathsByKey.get(key) ?? [];
312
- paths.push([...issue.path]);
319
+ if (issue.code === "unrecognized_keys") {
320
+ for (const unrecognizedKey of issue.keys) {
321
+ paths.push([...issue.path, unrecognizedKey]);
322
+ }
323
+ } else {
324
+ paths.push([...issue.path]);
325
+ }
313
326
  issuePathsByKey.set(key, paths);
314
327
  const msg = issue.message;
315
328
  if (msg && !GENERIC_ZOD_PREFIXES.some((p) => msg.startsWith(p))) {
@@ -324,21 +337,12 @@ function parsePluginConfig(
324
337
  for (const key of errorPaths) {
325
338
  recoveredTopLevelKeys.push(key);
326
339
  const isAgentConfig = key === "historian" || key === "dreamer" || key === "sidekick";
327
- if (isAgentConfig) {
328
- // Drop agent configs entirely on error — don't default them
329
- delete patched[key];
330
- warnings.push(
331
- `"${key}": invalid agent configuration, ignoring. Check your magic-context.jsonc.`,
332
- );
333
- continue;
334
- }
335
340
 
336
- // For object-valued keys (e.g. `memory`), prune ONLY the invalid nested
337
- // leaves and keep valid siblings, so one bad nested field doesn't wipe the
338
- // whole block — which would silently drop already-migrated graduated keys
339
- // like memory.auto_search / memory.git_commit_indexing. Falls back to
340
- // whole-key deletion when the issue is at the key itself or the value
341
- // isn't a prunable object.
341
+ // For object-valued keys (including agent harness blocks), prune only invalid
342
+ // nested leaves and keep valid siblings, so one bad field does not remove
343
+ // already-migrated keys such as memory.auto_search and
344
+ // memory.git_commit_indexing. Fall back to whole-key deletion when the issue
345
+ // is at the key itself or the value is not a prunable object.
342
346
  const issuePaths = issuePathsByKey.get(key) ?? [];
343
347
  const rawValue = rawConfig[key];
344
348
  const allNested =
@@ -364,10 +368,22 @@ function parsePluginConfig(
364
368
  prunedLeaves.push(result.removed);
365
369
  }
366
370
  }
367
- patched[key] = prunedBlock;
368
- const reason = customMessagesByKey.get(key);
371
+ if (prunedLeaves.length === issuePaths.length) {
372
+ patched[key] = prunedBlock;
373
+ const reason = customMessagesByKey.get(key);
374
+ warnings.push(
375
+ `"${key}": invalid nested field(s) ${prunedLeaves.map((leaf) => `"${key}.${leaf}"`).join(", ")}, using defaults for those.${reason ? ` ${reason}` : ""}`,
376
+ );
377
+ continue;
378
+ }
379
+ }
380
+
381
+ // Root-level or unreachable agent errors cannot be repaired safely because
382
+ // guessing a model configuration could select an expensive unintended model.
383
+ if (isAgentConfig) {
384
+ delete patched[key];
369
385
  warnings.push(
370
- `"${key}": invalid nested field(s) ${prunedLeaves.map((l) => `"${l}"`).join(", ")}, using defaults for those.${reason ? ` ${reason}` : ""}`,
386
+ `"${key}": invalid agent configuration, ignoring. Check your magic-context.jsonc.`,
371
387
  );
372
388
  continue;
373
389
  }
@@ -533,11 +549,6 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
533
549
  : null;
534
550
 
535
551
  const allWarnings: string[] = [];
536
- let mergedRaw: Record<string, unknown> = {};
537
- // Threshold trust boundary is relative to the USER/default effective config:
538
- // a cloned repo may delay compaction, but it may not lower thresholds in a
539
- // way that forces extra historian work on the user's account.
540
- const trustedBaseConfig = parsePluginConfig(userLoaded?.config ?? {});
541
552
 
542
553
  if (userLegacyFallback.source) {
543
554
  allWarnings.push(
@@ -561,26 +572,48 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
561
572
 
562
573
  if (userLoaded) {
563
574
  allWarnings.push(...userLoaded.warnings.map((w) => `[user config] ${w}`));
564
- mergedRaw = deepMergeRawConfig(mergedRaw, userLoaded.config);
565
575
  }
566
576
 
577
+ let projectRaw: Record<string, unknown> = {};
567
578
  if (projectLoaded) {
568
579
  allWarnings.push(...projectLoaded.warnings.map((w) => `[project config] ${w}`));
569
- const projectRaw = { ...projectLoaded.config };
580
+ projectRaw = { ...projectLoaded.config };
570
581
  for (const warning of stripUnsafeProjectConfigFields(projectRaw)) {
571
582
  allWarnings.push(`[project config] ${warning}`);
572
583
  }
573
- mergedRaw = deepMergeRawConfig(mergedRaw, projectRaw);
584
+ }
585
+
586
+ // Resolve profiles at the single user→project merge choke point. The profile
587
+ // definition is parsed from the trusted user tier, then its validated model
588
+ // overlay is merged before the untrusted project config. The raw selector is
589
+ // consumed here; only the resolved name remains as status metadata.
590
+ const profileResolution = resolveConfigProfile({
591
+ userRaw: userLoaded?.config ?? {},
592
+ projectRaw,
593
+ });
594
+ allWarnings.push(...profileResolution.warnings.map((warning) => `[config] ${warning}`));
595
+ const trustedProfiledRaw = deepMergeRawConfig(
596
+ profileResolution.userBase,
597
+ profileResolution.overlay,
598
+ );
599
+ let mergedRaw = trustedProfiledRaw;
600
+ // Threshold trust boundary is relative to the USER/default effective config:
601
+ // a cloned repo may delay compaction, but it may not lower thresholds in a
602
+ // way that forces extra historian work on the user's account.
603
+ const trustedBaseConfig = parsePluginConfig(trustedProfiledRaw);
604
+
605
+ if (projectLoaded) {
606
+ mergedRaw = deepMergeRawConfig(mergedRaw, profileResolution.projectBase);
574
607
  for (const warning of dropInheritedEmbeddingKeyOnRedirect(
575
608
  projectRaw,
576
609
  mergedRaw,
577
- userLoaded?.config,
610
+ profileResolution.userBase,
578
611
  )) {
579
612
  allWarnings.push(`[project config] ${warning}`);
580
613
  }
581
614
  for (const warning of constrainProjectThresholdOverrides({
582
615
  mergedRaw,
583
- projectRaw,
616
+ projectRaw: profileResolution.projectBase,
584
617
  trustedBaseConfig,
585
618
  })) {
586
619
  allWarnings.push(`[project config] ${warning}`);
@@ -589,6 +622,7 @@ export function loadPluginConfigDetailed(directory: string): LoadResultDetailed
589
622
 
590
623
  const recoveredTopLevelKeys: string[] = [];
591
624
  const config = parsePluginConfig(mergedRaw, recoveredTopLevelKeys);
625
+ if (profileResolution.activeProfile) config.profile = profileResolution.activeProfile;
592
626
  setOutputReserveConfig(config.output_reserve);
593
627
  setWindowOverlayPath(config.models?.window_overlay_path);
594
628
  if (config.configWarnings?.length) {
@@ -0,0 +1,106 @@
1
+ import { ConfigProfilesSchema } from "./schema/magic-context";
2
+
3
+ export interface ProfileResolution {
4
+ /** User config minus the profile declarations and selector. */
5
+ userBase: Record<string, unknown>;
6
+ /** Project config minus the profile declarations and selector. */
7
+ projectBase: Record<string, unknown>;
8
+ /** The validated user-owned overlay for the active profile, if any. */
9
+ overlay: Record<string, unknown>;
10
+ /** The selected profile name, absent when no valid profile is active. */
11
+ activeProfile?: string;
12
+ warnings: string[];
13
+ }
14
+
15
+ interface ProfileSelection {
16
+ declared: boolean;
17
+ name?: string;
18
+ }
19
+
20
+ function withoutProfileFields(raw: Record<string, unknown>): Record<string, unknown> {
21
+ const copy = { ...raw };
22
+ delete copy.profile;
23
+ delete copy.profiles;
24
+ return copy;
25
+ }
26
+
27
+ function readProfileSelection(raw: Record<string, unknown>): ProfileSelection {
28
+ if (!Object.hasOwn(raw, "profile")) return { declared: false };
29
+ const value = raw.profile;
30
+ if (typeof value !== "string") return { declared: true };
31
+ const name = value.trim();
32
+ return name.length > 0 ? { declared: true, name } : { declared: true };
33
+ }
34
+
35
+ /**
36
+ * Resolve the user-owned profile overlay without retaining any process-global
37
+ * selection state. Callers merge `userBase`, `overlay`, then `projectBase` for
38
+ * every project load, so a Pi /cd or OpenCode multi-root session cannot borrow
39
+ * another repository's selected profile.
40
+ */
41
+ export function resolveConfigProfile(args: {
42
+ userRaw: Record<string, unknown>;
43
+ projectRaw: Record<string, unknown>;
44
+ }): ProfileResolution {
45
+ const warnings: string[] = [];
46
+ const userSelection = readProfileSelection(args.userRaw);
47
+ const projectSelection = readProfileSelection(args.projectRaw);
48
+ const selection = projectSelection.name
49
+ ? { name: projectSelection.name, source: "project" }
50
+ : userSelection.name
51
+ ? { name: userSelection.name, source: "user" }
52
+ : undefined;
53
+
54
+ if (projectSelection.declared && !projectSelection.name) {
55
+ warnings.push(
56
+ "Ignoring invalid profile selection from project config; expected a non-empty string.",
57
+ );
58
+ }
59
+ if (!projectSelection.declared && userSelection.declared && !userSelection.name) {
60
+ warnings.push(
61
+ "Ignoring invalid profile selection from user config; expected a non-empty string.",
62
+ );
63
+ }
64
+
65
+ let profiles: Record<string, Record<string, unknown>> = {};
66
+ if (Object.hasOwn(args.userRaw, "profiles")) {
67
+ const parsed = ConfigProfilesSchema.safeParse(args.userRaw.profiles);
68
+ if (parsed.success) {
69
+ profiles = parsed.data as Record<string, Record<string, unknown>>;
70
+ } else {
71
+ warnings.push(
72
+ "Ignoring profiles from user config: invalid profile configuration; profiles may contain only historian/dreamer harness model blocks and sidekick model-selection fields.",
73
+ );
74
+ }
75
+ }
76
+
77
+ if (!selection) {
78
+ return {
79
+ userBase: withoutProfileFields(args.userRaw),
80
+ projectBase: withoutProfileFields(args.projectRaw),
81
+ overlay: {},
82
+ warnings,
83
+ };
84
+ }
85
+
86
+ if (!Object.hasOwn(profiles, selection.name)) {
87
+ warnings.push(
88
+ `Unknown profile "${selection.name}" selected by ${selection.source} config; using base config without a profile.`,
89
+ );
90
+ return {
91
+ userBase: withoutProfileFields(args.userRaw),
92
+ projectBase: withoutProfileFields(args.projectRaw),
93
+ overlay: {},
94
+ warnings,
95
+ };
96
+ }
97
+
98
+ const overlay = profiles[selection.name];
99
+ return {
100
+ userBase: withoutProfileFields(args.userRaw),
101
+ projectBase: withoutProfileFields(args.projectRaw),
102
+ overlay,
103
+ activeProfile: selection.name,
104
+ warnings,
105
+ };
106
+ }