@cortexkit/opencode-magic-context 0.38.0 → 0.39.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 (188) hide show
  1. package/README.md +37 -4
  2. package/dist/{announcement-6c1g4es4.js → announcement-5eg4t64s.js} +6 -6
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/project-security.d.ts +11 -6
  5. package/dist/config/project-security.d.ts.map +1 -1
  6. package/dist/config/raw-loader.d.ts +42 -0
  7. package/dist/config/raw-loader.d.ts.map +1 -0
  8. package/dist/config/schema/magic-context.d.ts +586 -551
  9. package/dist/config/schema/magic-context.d.ts.map +1 -1
  10. package/dist/{conflict-warning-hook-an4fxhwf.js → conflict-warning-hook-f406ggbw.js} +5 -5
  11. package/dist/{derive-budgets-da4yg3tz.js → derive-budgets-xk8cy5e6.js} +5 -5
  12. package/dist/{emscripten-module-ap8t3st0.js → emscripten-module-49k43zfy.js} +1 -1
  13. package/dist/{emscripten-module-682pfzaa.js → emscripten-module-ex4v0x4z.js} +1 -1
  14. package/dist/{emscripten-module-r5h7aghp.js → emscripten-module-jfs3d1dq.js} +1 -1
  15. package/dist/{emscripten-module-bkktz927.js → emscripten-module-ss4g4spn.js} +1 -1
  16. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  17. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  19. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  21. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  25. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  26. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts +17 -6
  27. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts.map +1 -1
  28. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  29. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  30. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  31. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  32. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  33. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  34. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  35. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  36. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  37. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  38. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  39. package/dist/features/magic-context/memory/types.d.ts +16 -0
  40. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  41. package/dist/features/magic-context/message-index.d.ts +3 -2
  42. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  43. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  44. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  45. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  47. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  48. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  49. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  50. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-db.d.ts +1 -1
  52. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-embedding-measurements.d.ts +1 -1
  54. package/dist/features/magic-context/storage-embedding-measurements.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  56. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  59. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  61. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  62. package/dist/features/magic-context/storage-session-tables.d.ts +18 -0
  63. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -0
  64. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  65. package/dist/features/magic-context/storage.d.ts +1 -1
  66. package/dist/features/magic-context/storage.d.ts.map +1 -1
  67. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  68. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  69. package/dist/features/magic-context/transform-decision-log.d.ts +33 -0
  70. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  71. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  72. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  73. package/dist/{ffi-d8pnn3mn.js → ffi-bbm9vkfa.js} +1 -1
  74. package/dist/{ffi-04jzypzk.js → ffi-kb5r0x1j.js} +1 -1
  75. package/dist/{ffi-t8b862vx.js → ffi-qnrt0y78.js} +1 -1
  76. package/dist/{ffi-9wvt2wwz.js → ffi-yx2hpf4p.js} +1 -1
  77. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  78. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  80. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  82. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  86. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  88. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  90. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/empty-task-output.d.ts +16 -0
  92. package/dist/hooks/magic-context/empty-task-output.d.ts.map +1 -0
  93. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/inject-compartments.d.ts +18 -1
  96. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  97. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  98. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  99. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  100. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  102. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  104. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  107. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  108. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  110. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  112. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
  114. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/transform.d.ts +9 -1
  116. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  118. package/dist/{index-cae89eas.js → index-1h9t9xb5.js} +2 -2
  119. package/dist/{index-wckvcay0.js → index-1yh8g550.js} +8 -6
  120. package/dist/{index-cgyfn1s2.js → index-2wchnyk6.js} +4 -2
  121. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  122. package/dist/{index-4cz5nfxf.js → index-btj2qhmc.js} +5 -5
  123. package/dist/index-ehpzdy0v.js +126 -0
  124. package/dist/{index-mmvhrqqb.js → index-k2ts0wk1.js} +6435 -4339
  125. package/dist/{index-9zhhvr4w.js → index-mg1yx1ph.js} +1 -1
  126. package/dist/{index-ayd60xnw.js → index-rgdqj8nj.js} +3 -3
  127. package/dist/{index-ygcng9t8.js → index-rpgg35ms.js} +50 -50
  128. package/dist/index-rw0qrkvq.js +560 -0
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +27158 -24736
  131. package/dist/model-resolution-r9jbcz86.js +15 -0
  132. package/dist/{module-ES6BEMUI-atfbtnbp.js → module-ES6BEMUI-1yjdz59c.js} +4 -4
  133. package/dist/{module-asyncify-2EFITU5U-7xckhw8w.js → module-asyncify-2EFITU5U-fdkx4fhy.js} +1 -1
  134. package/dist/plugin/dream-timer.d.ts +8 -0
  135. package/dist/plugin/dream-timer.d.ts.map +1 -1
  136. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  137. package/dist/plugin/messages-transform.d.ts +14 -6
  138. package/dist/plugin/messages-transform.d.ts.map +1 -1
  139. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  140. package/dist/{prompt-context-jpe2dstn.js → prompt-context-29qkb28f.js} +1 -1
  141. package/dist/{recomp-orchestrator-jsde4ygw.js → recomp-orchestrator-2pncxee7.js} +15 -16
  142. package/dist/{rpc-notifications-4d22ybn5.js → rpc-notifications-1ffm755w.js} +6 -6
  143. package/dist/{safe-notification-target-b693f9x3.js → safe-notification-target-tepzc2xa.js} +3 -3
  144. package/dist/{send-session-notification-dxske5zm.js → send-session-notification-6v5b72b4.js} +7 -7
  145. package/dist/shared/harness-provider-map.d.ts +6 -0
  146. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  147. package/dist/shared/jsonc-edit.d.ts +6 -1
  148. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  149. package/dist/shared/model-resolution.d.ts +53 -0
  150. package/dist/shared/model-resolution.d.ts.map +1 -0
  151. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  152. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  153. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  154. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  155. package/dist/shared/status-detail-text.d.ts +4 -0
  156. package/dist/shared/status-detail-text.d.ts.map +1 -0
  157. package/dist/shared/subagent-runner.d.ts +2 -1
  158. package/dist/shared/subagent-runner.d.ts.map +1 -1
  159. package/dist/{storage-meta-session-mk4gsnke.js → storage-meta-session-yvtbaw7c.js} +8 -8
  160. package/dist/{task-config-gbp7r23g.js → task-config-a1qmj9bx.js} +8 -8
  161. package/package.json +4 -2
  162. package/src/config/index.test.ts +73 -17
  163. package/src/config/index.ts +38 -23
  164. package/src/config/project-security.hostile-matrix.test.ts +471 -0
  165. package/src/config/project-security.test.ts +26 -5
  166. package/src/config/project-security.ts +141 -22
  167. package/src/config/raw-loader.test.ts +424 -0
  168. package/src/config/raw-loader.ts +490 -0
  169. package/src/config/schema/magic-context.test.ts +145 -2
  170. package/src/config/schema/magic-context.ts +256 -52
  171. package/src/shared/harness-provider-map.test.ts +36 -0
  172. package/src/shared/harness-provider-map.ts +8 -0
  173. package/src/shared/jsonc-edit.ts +74 -1
  174. package/src/shared/model-resolution.test.ts +170 -0
  175. package/src/shared/model-resolution.ts +227 -0
  176. package/src/shared/model-suggestion-retry.test.ts +27 -0
  177. package/src/shared/model-suggestion-retry.ts +19 -10
  178. package/src/shared/resolve-fallbacks.test.ts +13 -1
  179. package/src/shared/resolve-fallbacks.ts +19 -4
  180. package/src/shared/status-detail-text.test.ts +80 -0
  181. package/src/shared/status-detail-text.ts +56 -0
  182. package/src/shared/subagent-runner.ts +3 -1
  183. package/src/tui/index.tsx +1 -1
  184. package/src/tui/tui-compiled-runtime-imports.test.ts +3 -0
  185. package/src/tui-compiled/index.tsx +1 -1
  186. package/dist/index-crke4z5j.js +0 -3022
  187. package/dist/index-td9depx5.js +0 -46
  188. package/dist/resolve-fallbacks-5n5tvge1.js +0 -11
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ normalizeModelEntry,
5
+ resolveDreamerTaskModel,
6
+ resolveFallbackEntries,
7
+ resolveHistorianModel,
8
+ } from "./model-resolution";
9
+
10
+ describe("model-resolution", () => {
11
+ test("normalizes string and object entries to the same model identity", () => {
12
+ expect(normalizeModelEntry("anthropic/sonnet", "opencode")).toEqual({
13
+ model: "anthropic/sonnet",
14
+ });
15
+ expect(
16
+ normalizeModelEntry({ model: "anthropic/sonnet", variant: "high" }, "opencode"),
17
+ ).toEqual({ model: "anthropic/sonnet", qualifier: "high" });
18
+ });
19
+
20
+ test("keeps ordered qualifier-distinct fallbacks and removes exact duplicates", () => {
21
+ expect(
22
+ resolveFallbackEntries(
23
+ [
24
+ { model: "anthropic/sonnet", variant: "low" },
25
+ { model: "anthropic/sonnet", variant: "high" },
26
+ { model: "anthropic/sonnet", variant: "low" },
27
+ "google/flash",
28
+ "google/flash",
29
+ ],
30
+ "opencode",
31
+ ),
32
+ ).toEqual([
33
+ { model: "anthropic/sonnet", qualifier: "low" },
34
+ { model: "anthropic/sonnet", qualifier: "high" },
35
+ { model: "google/flash" },
36
+ ]);
37
+ });
38
+
39
+ test("does not inherit a block qualifier into unqualified fallbacks", () => {
40
+ const config = {
41
+ historian: {
42
+ opencode: {
43
+ model: "open/primary",
44
+ fallback_models: ["open/fallback"],
45
+ variant: "primary-only",
46
+ },
47
+ pi: {
48
+ model: "pi/primary",
49
+ fallback_models: ["pi/fallback"],
50
+ thinking_level: "high",
51
+ },
52
+ },
53
+ };
54
+
55
+ // Inheritance is deliberately absent because a fallback model may not support the
56
+ // qualifier selected for the primary model.
57
+ expect(resolveHistorianModel(config, "opencode")).toEqual({
58
+ primary: { model: "open/primary", qualifier: "primary-only" },
59
+ fallbacks: [{ model: "open/fallback" }],
60
+ });
61
+ expect(resolveHistorianModel(config, "pi")).toEqual({
62
+ primary: { model: "pi/primary", qualifier: "high" },
63
+ fallbacks: [{ model: "pi/fallback" }],
64
+ });
65
+ });
66
+
67
+ test("reads historian attempts only from the requested harness", () => {
68
+ const config = {
69
+ historian: {
70
+ model: "flat/ignored",
71
+ opencode: {
72
+ model: { model: "open/code", variant: "oc-primary" },
73
+ fallback_models: [{ model: "open/fallback", variant: "oc-fallback" }],
74
+ },
75
+ pi: {
76
+ model: { model: "pi/model", thinking_level: "high" },
77
+ fallback_models: [{ model: "pi/fallback", thinking_level: "max" }],
78
+ },
79
+ },
80
+ };
81
+
82
+ expect(resolveHistorianModel(config, "opencode")).toEqual({
83
+ primary: { model: "open/code", qualifier: "oc-primary" },
84
+ fallbacks: [{ model: "open/fallback", qualifier: "oc-fallback" }],
85
+ });
86
+ expect(resolveHistorianModel(config, "pi")).toEqual({
87
+ primary: { model: "pi/model", qualifier: "high" },
88
+ fallbacks: [{ model: "pi/fallback", qualifier: "max" }],
89
+ });
90
+ });
91
+
92
+ test("resolves ordinary task model and scheduling without crossing harnesses", () => {
93
+ const config = {
94
+ dreamer: {
95
+ tasks: {
96
+ curate: { schedule: "0 4 * * *", promotion_threshold: 3 },
97
+ },
98
+ opencode: {
99
+ model: { model: "open/default", variant: "default-variant" },
100
+ tasks: {
101
+ curate: {
102
+ model: { model: "open/task", variant: "task-variant" },
103
+ fallback_models: [
104
+ "open/bare",
105
+ { model: "open/qualified", variant: "fb" },
106
+ ],
107
+ timeout_minutes: 12,
108
+ },
109
+ },
110
+ },
111
+ pi: {
112
+ model: { model: "pi/default", thinking_level: "high" },
113
+ tasks: {
114
+ curate: {
115
+ model: { model: "pi/task", thinking_level: "max" },
116
+ fallback_models: [{ model: "pi/fallback", thinking_level: "minimal" }],
117
+ },
118
+ },
119
+ },
120
+ },
121
+ };
122
+
123
+ expect(resolveDreamerTaskModel({ config, harness: "opencode", task: "curate" })).toEqual({
124
+ primary: { model: "open/task", qualifier: "task-variant" },
125
+ fallbacks: [{ model: "open/bare" }, { model: "open/qualified", qualifier: "fb" }],
126
+ schedule: "0 4 * * *",
127
+ timeoutMinutes: 12,
128
+ promotionThreshold: 3,
129
+ });
130
+ });
131
+
132
+ test("uses mural model between compress-cues task and harness default", () => {
133
+ const config = {
134
+ dreamer: {
135
+ tasks: { "compress-cues": { schedule: "0 4 * * *" } },
136
+ opencode: {
137
+ model: { model: "open/default", variant: "default-variant" },
138
+ tasks: { "compress-cues": { variant: "task-local-must-not-leak" } },
139
+ },
140
+ pi: {
141
+ model: { model: "pi/default", thinking_level: "high" },
142
+ tasks: { "compress-cues": {} },
143
+ },
144
+ },
145
+ };
146
+
147
+ expect(
148
+ resolveDreamerTaskModel({
149
+ config,
150
+ harness: "opencode",
151
+ task: "compress-cues",
152
+ muralModel: "mural/model",
153
+ }),
154
+ ).toMatchObject({
155
+ primary: { model: "mural/model", qualifier: "default-variant" },
156
+ fallbacks: [],
157
+ });
158
+ expect(
159
+ resolveDreamerTaskModel({
160
+ config,
161
+ harness: "pi",
162
+ task: "compress-cues",
163
+ muralModel: "mural/model",
164
+ }),
165
+ ).toMatchObject({
166
+ primary: { model: "mural/model", qualifier: "high" },
167
+ fallbacks: [],
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,227 @@
1
+ export type ModelHarness = "opencode" | "pi";
2
+
3
+ export interface ResolvedModelEntry {
4
+ /** Canonical provider/model reference used for identity and model selection. */
5
+ model: string;
6
+ /** OpenCode variant or Pi thinking level, selected for the active entry only. */
7
+ qualifier?: string;
8
+ }
9
+
10
+ export type ModelInput = string | ResolvedModelEntry;
11
+
12
+ type ConfigRecord = Record<string, unknown>;
13
+
14
+ function asRecord(value: unknown): ConfigRecord | undefined {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value)
16
+ ? (value as ConfigRecord)
17
+ : undefined;
18
+ }
19
+
20
+ function readString(value: unknown): string | undefined {
21
+ if (typeof value !== "string") return undefined;
22
+ const trimmed = value.trim();
23
+ return trimmed.length > 0 ? trimmed : undefined;
24
+ }
25
+
26
+ function hasOwn(record: ConfigRecord, key: string): boolean {
27
+ return Object.hasOwn(record, key);
28
+ }
29
+
30
+ /**
31
+ * Collapses the string and object spellings of a harness model entry into the
32
+ * same model identity. Qualifiers stay separate because they alter an attempt,
33
+ * not the configured model identity.
34
+ */
35
+ export function normalizeModelEntry(
36
+ entry: unknown,
37
+ harness: ModelHarness,
38
+ ): ResolvedModelEntry | undefined {
39
+ if (typeof entry === "string") {
40
+ const model = readString(entry);
41
+ return model ? { model } : undefined;
42
+ }
43
+
44
+ const objectEntry = asRecord(entry);
45
+ if (!objectEntry) return undefined;
46
+ const model = readString(objectEntry.model);
47
+ if (!model) return undefined;
48
+
49
+ const qualifier = readString(
50
+ objectEntry[harness === "opencode" ? "variant" : "thinking_level"],
51
+ );
52
+ return qualifier ? { model, qualifier } : { model };
53
+ }
54
+
55
+ function isValidModelReference(entry: ResolvedModelEntry): boolean {
56
+ const slash = entry.model.indexOf("/");
57
+ return slash > 0 && slash < entry.model.length - 1;
58
+ }
59
+
60
+ function sameAttempt(a: ResolvedModelEntry, b: ResolvedModelEntry): boolean {
61
+ return a.model === b.model && a.qualifier === b.qualifier;
62
+ }
63
+
64
+ /**
65
+ * Resolves a configured fallback list in declaration order. Attempts are
66
+ * deduplicated by model plus qualifier: the same model with distinct
67
+ * qualifiers remains two intentional attempts.
68
+ */
69
+ export function resolveFallbackEntries(
70
+ value: unknown,
71
+ harness: ModelHarness,
72
+ ): ResolvedModelEntry[] {
73
+ const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
74
+ const resolved: ResolvedModelEntry[] = [];
75
+ for (const value of values) {
76
+ const entry = normalizeModelEntry(value, harness);
77
+ if (!entry || !isValidModelReference(entry)) continue;
78
+ if (resolved.some((candidate) => sameAttempt(candidate, entry))) continue;
79
+ resolved.push(entry);
80
+ }
81
+ return resolved;
82
+ }
83
+
84
+ function resolvePrimaryEntry(args: {
85
+ entry: unknown;
86
+ defaultQualifier: unknown;
87
+ harness: ModelHarness;
88
+ }): ResolvedModelEntry | undefined {
89
+ const entry = normalizeModelEntry(args.entry, args.harness);
90
+ if (!entry) return undefined;
91
+ const qualifier = entry.qualifier ?? readString(args.defaultQualifier);
92
+ return qualifier ? { ...entry, qualifier } : entry;
93
+ }
94
+
95
+ export interface ResolvedHistorianModel {
96
+ primary?: ResolvedModelEntry;
97
+ fallbacks: ResolvedModelEntry[];
98
+ }
99
+
100
+ /**
101
+ * Resolve historian model attempts from exactly one harness block. Flat keys
102
+ * and the other harness are deliberately never consulted after cutover.
103
+ */
104
+ export function resolveHistorianModel(
105
+ config: unknown,
106
+ harness: ModelHarness,
107
+ ): ResolvedHistorianModel {
108
+ const historian = asRecord(asRecord(config)?.historian);
109
+ const block = asRecord(historian?.[harness]);
110
+ if (!block) return { fallbacks: [] };
111
+
112
+ return {
113
+ primary: resolvePrimaryEntry({
114
+ entry: block.model,
115
+ defaultQualifier: block[harness === "opencode" ? "variant" : "thinking_level"],
116
+ harness,
117
+ }),
118
+ fallbacks: resolveFallbackEntries(block.fallback_models, harness),
119
+ };
120
+ }
121
+
122
+ export interface ResolvedDreamerTaskModel {
123
+ primary?: ResolvedModelEntry;
124
+ fallbacks: ResolvedModelEntry[];
125
+ schedule?: string;
126
+ timeoutMinutes?: number;
127
+ promotionThreshold?: number;
128
+ }
129
+
130
+ /**
131
+ * Resolve a dreamer task from one harness subtree. Model selection is task →
132
+ * harness default, except compress-cues inserts its harness-independent mural
133
+ * string between those two rungs. Scheduling remains at dreamer.tasks.<task>.
134
+ */
135
+ export function resolveDreamerTaskModel(args: {
136
+ config: unknown;
137
+ harness: ModelHarness;
138
+ task: string;
139
+ muralModel?: unknown;
140
+ }): ResolvedDreamerTaskModel {
141
+ const dreamer = asRecord(asRecord(args.config)?.dreamer);
142
+ const harnessBlock = asRecord(dreamer?.[args.harness]);
143
+ const taskBlock = asRecord(asRecord(harnessBlock?.tasks)?.[args.task]);
144
+ const schedulingTask = asRecord(asRecord(dreamer?.tasks)?.[args.task]);
145
+ if (!harnessBlock) {
146
+ return {
147
+ fallbacks: [],
148
+ schedule: readString(schedulingTask?.schedule),
149
+ timeoutMinutes:
150
+ typeof taskBlock?.timeout_minutes === "number"
151
+ ? taskBlock.timeout_minutes
152
+ : undefined,
153
+ promotionThreshold:
154
+ typeof schedulingTask?.promotion_threshold === "number"
155
+ ? schedulingTask.promotion_threshold
156
+ : undefined,
157
+ };
158
+ }
159
+
160
+ const taskQualifier = taskBlock?.[args.harness === "opencode" ? "variant" : "thinking_level"];
161
+ // A mural string borrows the harness default's qualifier whether that
162
+ // qualifier is written beside `model` or inside the default entry object.
163
+ const harnessQualifier =
164
+ harnessBlock[args.harness === "opencode" ? "variant" : "thinking_level"] ??
165
+ normalizeModelEntry(harnessBlock.model, args.harness)?.qualifier;
166
+ const taskHasModel = taskBlock !== undefined && hasOwn(taskBlock, "model");
167
+ const usesMuralModel =
168
+ !taskHasModel && args.task === "compress-cues" && Boolean(readString(args.muralModel));
169
+ const primarySource = taskHasModel
170
+ ? taskBlock?.model
171
+ : usesMuralModel
172
+ ? args.muralModel
173
+ : harnessBlock.model;
174
+ const fallbackSource =
175
+ taskBlock !== undefined && hasOwn(taskBlock, "fallback_models")
176
+ ? taskBlock.fallback_models
177
+ : harnessBlock.fallback_models;
178
+
179
+ return {
180
+ primary: resolvePrimaryEntry({
181
+ entry: primarySource,
182
+ // mural.model is a plain string, so its qualifier is always the
183
+ // executing harness default rather than a task-local default.
184
+ defaultQualifier: usesMuralModel
185
+ ? harnessQualifier
186
+ : (taskQualifier ?? harnessQualifier),
187
+ harness: args.harness,
188
+ }),
189
+ fallbacks: resolveFallbackEntries(fallbackSource, args.harness),
190
+ schedule: readString(schedulingTask?.schedule),
191
+ timeoutMinutes:
192
+ typeof taskBlock?.timeout_minutes === "number" ? taskBlock.timeout_minutes : undefined,
193
+ promotionThreshold:
194
+ typeof schedulingTask?.promotion_threshold === "number"
195
+ ? schedulingTask.promotion_threshold
196
+ : undefined,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Keep non-model agent settings at their original level while replacing only
202
+ * OpenCode's model-resolution fields with the executing harness's primary.
203
+ */
204
+ export function resolveOpenCodeAgentOverrides(agent: unknown): Record<string, unknown> {
205
+ const source = asRecord(agent);
206
+ if (!source) return {};
207
+ const {
208
+ model: _model,
209
+ fallback_models: _fallbackModels,
210
+ variant: _variant,
211
+ thinking_level: _thinking,
212
+ opencode,
213
+ pi,
214
+ tasks,
215
+ ...rest
216
+ } = source;
217
+ const primary = resolvePrimaryEntry({
218
+ entry: asRecord(opencode)?.model,
219
+ defaultQualifier: asRecord(opencode)?.variant,
220
+ harness: "opencode",
221
+ });
222
+ return {
223
+ ...rest,
224
+ ...(primary ? { model: primary.model } : {}),
225
+ ...(primary?.qualifier ? { variant: primary.qualifier } : {}),
226
+ };
227
+ }
@@ -56,6 +56,33 @@ describe("promptSyncWithModelSuggestionRetry", () => {
56
56
  expect(prompt).toHaveBeenCalledTimes(1);
57
57
  });
58
58
 
59
+ test("each active fallback sends only its own variant", async () => {
60
+ const prompt = mock(async () => {
61
+ if (prompt.mock.calls.length <= 2) throw new Error("attempt failed");
62
+ return {};
63
+ });
64
+ const client = createClient(prompt);
65
+
66
+ await promptSyncWithModelSuggestionRetry(
67
+ client,
68
+ {
69
+ path: { id: "ses-test" },
70
+ body: { model: { providerID: "anthropic", modelID: "primary" }, variant: "high" },
71
+ },
72
+ {
73
+ fallbackModels: [{ model: "anthropic/fallback", qualifier: "low" }, "google/bare"],
74
+ },
75
+ );
76
+
77
+ expect((prompt.mock.calls[1]?.[0] as PromptCall).body).toMatchObject({
78
+ model: { providerID: "anthropic", modelID: "fallback" },
79
+ variant: "low",
80
+ });
81
+ const bareFallbackBody = (prompt.mock.calls[2]?.[0] as PromptCall).body;
82
+ expect(bareFallbackBody.model).toEqual({ providerID: "google", modelID: "bare" });
83
+ expect(bareFallbackBody.variant).toBeUndefined();
84
+ });
85
+
59
86
  test("primary fails, fallback[0] succeeds", async () => {
60
87
  const prompt = mock(async () => {
61
88
  if (prompt.mock.calls.length === 1) throw new Error("primary failed");
@@ -2,7 +2,8 @@ import type { createOpencodeClient } from "@opencode-ai/sdk";
2
2
 
3
3
  import { detectOverflow } from "../features/magic-context/overflow-detection";
4
4
  import { log } from "./logger";
5
- import { parseProviderModel } from "./resolve-fallbacks";
5
+ import type { ModelInput } from "./model-resolution";
6
+ import { parseProviderModel, toModelEntry } from "./resolve-fallbacks";
6
7
 
7
8
  type Client = ReturnType<typeof createOpencodeClient>;
8
9
 
@@ -13,6 +14,8 @@ const ABORT_CALL_TIMEOUT_MS = 3000;
13
14
 
14
15
  export type PromptBody = {
15
16
  model?: { providerID: string; modelID: string };
17
+ /** OpenCode variant; Pi's facade also uses it as the active thinking level. */
18
+ variant?: string;
16
19
  [key: string]: unknown;
17
20
  };
18
21
 
@@ -64,7 +67,7 @@ export interface PromptRetryOptions {
64
67
  * - On all-failed, the LAST error is thrown (matches legacy behavior when
65
68
  * `fallbackModels` is empty).
66
69
  */
67
- fallbackModels?: readonly string[];
70
+ fallbackModels?: readonly ModelInput[];
68
71
  /**
69
72
  * Identifier for structured logging (e.g. "dreamer:consolidate",
70
73
  * "historian", "compressor", "sidekick"). Helps correlate fallback
@@ -394,16 +397,19 @@ export async function promptSyncWithModelSuggestionRetry(
394
397
 
395
398
  // Iterate fallbacks.
396
399
  for (let i = 0; i < fallbacks.length; i += 1) {
397
- const parsed = parseProviderModel(fallbacks[i]);
400
+ const fallback = toModelEntry(fallbacks[i]);
401
+ const parsed = fallback ? parseProviderModel(fallback.model) : null;
398
402
  if (!parsed) {
399
- log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
403
+ log(`[${callContext}] skipping invalid fallback spec: ${String(fallbacks[i])}`);
400
404
  continue;
401
405
  }
402
406
 
403
407
  const label = `${parsed.providerID}/${parsed.modelID}`;
408
+ const { variant: _primaryVariant, ...bodyWithoutPrimaryVariant } = baseBody;
404
409
  const attemptArgs = copyPromptArgs(baseArgs, {
405
- ...baseBody,
410
+ ...bodyWithoutPrimaryVariant,
406
411
  model: parsed,
412
+ ...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
407
413
  });
408
414
 
409
415
  try {
@@ -429,7 +435,7 @@ export async function promptSyncWithModelSuggestionRetry(
429
435
  // caller's report (e.g. /ctx-dream tasks_json) still surfaces a real
430
436
  // diagnostic.
431
437
  log(
432
- `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; last error: ${shortErr(lastError)}`,
438
+ `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks.map((fallback) => toModelEntry(fallback)?.model ?? String(fallback))].join(", ")}; last error: ${shortErr(lastError)}`,
433
439
  );
434
440
  throw lastError ?? new Error("All fallback models failed");
435
441
  }
@@ -515,16 +521,19 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
515
521
  }
516
522
 
517
523
  for (let i = 0; i < fallbacks.length; i += 1) {
518
- const parsed = parseProviderModel(fallbacks[i]);
524
+ const fallback = toModelEntry(fallbacks[i]);
525
+ const parsed = fallback ? parseProviderModel(fallback.model) : null;
519
526
  if (!parsed) {
520
- log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
527
+ log(`[${callContext}] skipping invalid fallback spec: ${String(fallbacks[i])}`);
521
528
  continue;
522
529
  }
523
530
 
524
531
  const label = `${parsed.providerID}/${parsed.modelID}`;
532
+ const { variant: _primaryVariant, ...bodyWithoutPrimaryVariant } = baseBody;
525
533
  const attemptArgs = copyPromptArgs(baseArgs, {
526
- ...baseBody,
534
+ ...bodyWithoutPrimaryVariant,
527
535
  model: parsed,
536
+ ...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
528
537
  });
529
538
  const attempt: PromptAttemptInfo = {
530
539
  label,
@@ -563,7 +572,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
563
572
  }
564
573
 
565
574
  log(
566
- `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
575
+ `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks.map((fallback) => toModelEntry(fallback)?.model ?? String(fallback))].join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
567
576
  );
568
577
  throw firstError ?? lastError ?? new Error("All fallback models failed validation");
569
578
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
3
+ import { modelBodyField, parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
4
4
 
5
5
  describe("resolveFallbackChain", () => {
6
6
  // Policy: user-config-only. There is NO builtin provider-agnostic chain —
@@ -62,6 +62,18 @@ describe("resolveFallbackChain", () => {
62
62
  });
63
63
  });
64
64
 
65
+ describe("modelBodyField", () => {
66
+ test("sends the active entry variant and leaves a bare entry unqualified", () => {
67
+ expect(modelBodyField({ model: "anthropic/claude-sonnet", qualifier: "high" })).toEqual({
68
+ model: { providerID: "anthropic", modelID: "claude-sonnet" },
69
+ variant: "high",
70
+ });
71
+ expect(modelBodyField("google/gemini-flash")).toEqual({
72
+ model: { providerID: "google", modelID: "gemini-flash" },
73
+ });
74
+ });
75
+ });
76
+
65
77
  describe("parseProviderModel", () => {
66
78
  test("parses standard provider/model", () => {
67
79
  expect(parseProviderModel("anthropic/claude-sonnet-4-6")).toEqual({
@@ -1,3 +1,5 @@
1
+ import type { ModelInput, ResolvedModelEntry } from "./model-resolution";
2
+
1
3
  /**
2
4
  * Resolve the fallback model list to attempt for a hidden-agent (historian /
3
5
  * dreamer / sidekick) call when its configured primary fails.
@@ -71,10 +73,23 @@ export function parseProviderModel(spec: string): { providerID: string; modelID:
71
73
  * unparseable (the session falls back to its default model). Spread into a
72
74
  * `client.session.prompt` body.
73
75
  */
74
- export function modelBodyField(spec: string | undefined): {
76
+ export function modelBodyField(spec: ModelInput | undefined): {
75
77
  model?: { providerID: string; modelID: string };
78
+ variant?: string;
76
79
  } {
77
- if (!spec) return {};
78
- const parsed = parseProviderModel(spec);
79
- return parsed ? { model: parsed } : {};
80
+ const entry = toModelEntry(spec);
81
+ if (!entry) return {};
82
+ const parsed = parseProviderModel(entry.model);
83
+ return parsed
84
+ ? { model: parsed, ...(entry.qualifier ? { variant: entry.qualifier } : {}) }
85
+ : {};
86
+ }
87
+
88
+ /** Convert legacy string call sites and resolved entries into one active attempt shape. */
89
+ export function toModelEntry(spec: ModelInput | undefined): ResolvedModelEntry | undefined {
90
+ if (typeof spec === "string") {
91
+ const model = spec.trim();
92
+ return model ? { model } : undefined;
93
+ }
94
+ return spec;
80
95
  }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { StatusDetail } from "./rpc-types";
3
+ import { formatStatusDetailMarkdown } from "./status-detail-text";
4
+
5
+ const STATUS_FIXTURE: StatusDetail = {
6
+ sessionId: "ses_status",
7
+ usagePercentage: 75,
8
+ inputTokens: 96_000,
9
+ contextLimit: 128_000,
10
+ systemPromptTokens: 4_000,
11
+ compartmentCount: 12,
12
+ memoryCount: 8,
13
+ memoryBlockCount: 3,
14
+ pendingOpsCount: 2,
15
+ historianRunning: true,
16
+ compartmentInProgress: true,
17
+ sessionNoteCount: 1,
18
+ readySmartNoteCount: 2,
19
+ cacheTtl: "5m",
20
+ lastTransformError: null,
21
+ lastDreamerRunAt: null,
22
+ projectIdentity: "/repo",
23
+ compartmentTokens: 22_000,
24
+ factTokens: 0,
25
+ memoryTokens: 1_200,
26
+ docsTokens: 500,
27
+ profileTokens: 100,
28
+ conversationTokens: 62_000,
29
+ toolCallTokens: 3_000,
30
+ toolDefinitionTokens: 3_200,
31
+ executeThreshold: 65,
32
+ executeThresholdClamped: false,
33
+ boundaryPresent: true,
34
+ coverageOrdinal: 12,
35
+ newWorkTokens: 1_000,
36
+ totalInputTokens: 96_000,
37
+ recompProgress: null,
38
+ tagCounter: 5,
39
+ activeTags: 4,
40
+ droppedTags: 1,
41
+ totalTags: 5,
42
+ activeBytes: 2_048,
43
+ lastResponseTime: 1,
44
+ lastNudgeTokens: 80_000,
45
+ pendingOps: [],
46
+ cacheTtlMs: 300_000,
47
+ cacheRemainingMs: 42_000,
48
+ cacheExpired: false,
49
+ cacheNeverExpires: false,
50
+ executeThresholdMode: "percentage",
51
+ protectedTagCount: 20,
52
+ historyBudgetPercentage: 0.15,
53
+ historyBlockTokens: 22_000,
54
+ compressionBudget: 12_480,
55
+ compressionUsage: "176%",
56
+ toastDurationMs: 5_000,
57
+ loggerDiagnostics: {
58
+ swallowedWriteCount: 0,
59
+ lastErrorMessage: null,
60
+ lastErrorTime: null,
61
+ },
62
+ storage_versions: {
63
+ context_db_schema_version: 79,
64
+ plugin_supported_version: 79,
65
+ },
66
+ };
67
+
68
+ describe("formatStatusDetailMarkdown", () => {
69
+ test("renders the fixed TUI status payload as compact markdown", () => {
70
+ expect(formatStatusDetailMarkdown(STATUS_FIXTURE)).toBe(`## Magic Context Status
71
+
72
+ - **Mode:** Magic Context compaction
73
+ - **Usage:** 75.0% (96,000 / 128,000 usable tokens)
74
+ - **Cache lane:** live (42s remaining); TTL 5m
75
+ - **Historian:** running; boundary present; coverage 12
76
+ - **Memory:** 8 active; 3 injected
77
+ - **Tags:** 4 active, 1 dropped; 2 pending drops
78
+ - **Execute threshold:** 65.0%`);
79
+ });
80
+ });