@cortexkit/opencode-magic-context 0.38.1 → 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 (143) hide show
  1. package/README.md +37 -4
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +11 -6
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/raw-loader.d.ts +42 -0
  6. package/dist/config/raw-loader.d.ts.map +1 -0
  7. package/dist/config/schema/magic-context.d.ts +585 -550
  8. package/dist/config/schema/magic-context.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/classify.d.ts +3 -2
  10. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +9 -2
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +4 -2
  14. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories.d.ts +21 -5
  16. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +3 -2
  18. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/task-config.d.ts +2 -1
  20. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -1
  22. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -2
  24. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  26. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify.d.ts +6 -3
  28. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  29. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  30. package/dist/features/magic-context/mural/compress-cues.d.ts +3 -2
  31. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  32. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  33. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  34. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +2 -0
  35. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  36. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage-db.d.ts +1 -1
  38. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -2
  40. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  42. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  44. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  45. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  46. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  47. package/dist/features/magic-context/storage.d.ts +1 -1
  48. package/dist/features/magic-context/storage.d.ts.map +1 -1
  49. package/dist/features/magic-context/tool-definition-tokens.d.ts +10 -0
  50. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  51. package/dist/features/magic-context/transform-decision-log.d.ts +11 -0
  52. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  53. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +3 -2
  54. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -1
  56. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/command-handler.d.ts +4 -0
  58. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +4 -1
  60. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/compartment-runner-types.d.ts +4 -1
  64. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +14 -2
  66. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  68. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/inject-compartments.d.ts +12 -1
  72. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/lkg-persist.d.ts +14 -0
  74. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -0
  75. package/dist/hooks/magic-context/lkg-slot.d.ts +13 -0
  76. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  78. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/module-transport.d.ts +12 -0
  80. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +4 -1
  83. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +12 -0
  86. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +3 -1
  88. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +4 -0
  90. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/transform.d.ts +9 -1
  92. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  94. package/dist/{index-2y0ybv5x.js → index-3dkzzh0t.js} +15 -17
  95. package/dist/index-ehpzdy0v.js +126 -0
  96. package/dist/{index-5znjkjws.js → index-k2ts0wk1.js} +486 -106
  97. package/dist/{index-22mw8km4.js → index-rw0qrkvq.js} +4 -2
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +17186 -14853
  100. package/dist/model-resolution-r9jbcz86.js +15 -0
  101. package/dist/plugin/dream-timer.d.ts +3 -0
  102. package/dist/plugin/dream-timer.d.ts.map +1 -1
  103. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  104. package/dist/plugin/messages-transform.d.ts +14 -6
  105. package/dist/plugin/messages-transform.d.ts.map +1 -1
  106. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  107. package/dist/{recomp-orchestrator-p030kxtc.js → recomp-orchestrator-2pncxee7.js} +2 -3
  108. package/dist/shared/jsonc-edit.d.ts +6 -1
  109. package/dist/shared/jsonc-edit.d.ts.map +1 -1
  110. package/dist/shared/model-resolution.d.ts +53 -0
  111. package/dist/shared/model-resolution.d.ts.map +1 -0
  112. package/dist/shared/model-suggestion-retry.d.ts +4 -1
  113. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  114. package/dist/shared/resolve-fallbacks.d.ts +5 -1
  115. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  116. package/dist/shared/status-detail-text.d.ts +4 -0
  117. package/dist/shared/status-detail-text.d.ts.map +1 -0
  118. package/dist/shared/subagent-runner.d.ts +2 -1
  119. package/dist/shared/subagent-runner.d.ts.map +1 -1
  120. package/dist/{storage-meta-session-2306t3j7.js → storage-meta-session-yvtbaw7c.js} +1 -1
  121. package/dist/{task-config-at3adc59.js → task-config-a1qmj9bx.js} +2 -2
  122. package/package.json +4 -2
  123. package/src/config/index.test.ts +73 -17
  124. package/src/config/index.ts +38 -23
  125. package/src/config/project-security.hostile-matrix.test.ts +471 -0
  126. package/src/config/project-security.test.ts +26 -5
  127. package/src/config/project-security.ts +141 -22
  128. package/src/config/raw-loader.test.ts +424 -0
  129. package/src/config/raw-loader.ts +490 -0
  130. package/src/config/schema/magic-context.test.ts +144 -2
  131. package/src/config/schema/magic-context.ts +253 -49
  132. package/src/shared/jsonc-edit.ts +74 -1
  133. package/src/shared/model-resolution.test.ts +170 -0
  134. package/src/shared/model-resolution.ts +227 -0
  135. package/src/shared/model-suggestion-retry.test.ts +27 -0
  136. package/src/shared/model-suggestion-retry.ts +19 -10
  137. package/src/shared/resolve-fallbacks.test.ts +13 -1
  138. package/src/shared/resolve-fallbacks.ts +19 -4
  139. package/src/shared/status-detail-text.test.ts +80 -0
  140. package/src/shared/status-detail-text.ts +56 -0
  141. package/src/shared/subagent-runner.ts +3 -1
  142. package/dist/index-td9depx5.js +0 -46
  143. package/dist/resolve-fallbacks-qf0ekmz9.js +0 -11
@@ -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
+ });
@@ -0,0 +1,56 @@
1
+ import type { StatusDetail } from "./rpc-types";
2
+
3
+ function formatCount(value: number): string {
4
+ return Math.round(value).toLocaleString();
5
+ }
6
+
7
+ function formatCacheLane(detail: StatusDetail): string {
8
+ if (detail.cacheNeverExpires) return `never expires; TTL ${detail.cacheTtl}`;
9
+ if (detail.lastResponseTime <= 0) return `waiting for first response; TTL ${detail.cacheTtl}`;
10
+ if (detail.cacheExpired) return `expired; TTL ${detail.cacheTtl}`;
11
+ return `live (${Math.round(detail.cacheRemainingMs / 1000)}s remaining); TTL ${detail.cacheTtl}`;
12
+ }
13
+
14
+ /** Render the same StatusDetail payload that powers the TUI dialog for chat-only clients. */
15
+ export function formatStatusDetailMarkdown(detail: StatusDetail): string {
16
+ const usableLimit =
17
+ detail.contextLimit > 0
18
+ ? `${formatCount(detail.contextLimit)} usable tokens`
19
+ : "? usable tokens";
20
+ const historianState = detail.historianRunning ? "running" : "idle";
21
+ const historianDetails = [
22
+ detail.boundaryPresent === undefined
23
+ ? undefined
24
+ : `boundary ${detail.boundaryPresent ? "present" : "absent"}`,
25
+ detail.coverageOrdinal === undefined
26
+ ? undefined
27
+ : `coverage ${detail.coverageOrdinal === null ? "none" : detail.coverageOrdinal}`,
28
+ ].filter((value): value is string => value !== undefined);
29
+ const mode =
30
+ detail.compaction_enabled === false
31
+ ? "native compaction (Magic Context history compaction disabled)"
32
+ : "Magic Context compaction";
33
+
34
+ const lines = [
35
+ "## Magic Context Status",
36
+ "",
37
+ `- **Mode:** ${mode}`,
38
+ `- **Usage:** ${detail.usagePercentage.toFixed(1)}% (${formatCount(detail.inputTokens)} / ${usableLimit})`,
39
+ `- **Cache lane:** ${formatCacheLane(detail)}`,
40
+ `- **Historian:** ${[historianState, ...historianDetails].join("; ")}`,
41
+ `- **Memory:** ${formatCount(detail.memoryCount)} active; ${formatCount(detail.memoryBlockCount)} injected`,
42
+ `- **Tags:** ${formatCount(detail.activeTags)} active, ${formatCount(detail.droppedTags)} dropped; ${formatCount(detail.pendingOpsCount)} pending drops`,
43
+ `- **Execute threshold:** ${detail.executeThreshold.toFixed(1)}%${detail.executeThresholdClamped ? " (clamped)" : ""}`,
44
+ ];
45
+
46
+ if (detail.recompProgress?.phase === "recomp") {
47
+ lines.push(
48
+ `- **${detail.recompProgress.kind === "embed" ? "Embed" : "Historian"} progress:** ${formatCount(detail.recompProgress.processedMessages)} / ${formatCount(detail.recompProgress.totalMessages)}`,
49
+ );
50
+ }
51
+ if (detail.lastTransformError) {
52
+ lines.push(`- **Last transform error:** ${detail.lastTransformError}`);
53
+ }
54
+
55
+ return lines.join("\n");
56
+ }
@@ -29,6 +29,8 @@
29
29
  * the runner contract is purely additive on the OpenCode side.
30
30
  */
31
31
 
32
+ import type { ModelInput } from "./model-resolution";
33
+
32
34
  /**
33
35
  * Configuration for one subagent invocation.
34
36
  *
@@ -63,7 +65,7 @@ export interface SubagentRunOptions {
63
65
  systemPrompt: string;
64
66
  userMessage: string;
65
67
  model?: string | undefined;
66
- fallbackModels?: readonly string[];
68
+ fallbackModels?: readonly ModelInput[];
67
69
  timeoutMs?: number | undefined;
68
70
  cwd?: string | undefined;
69
71
  signal?: AbortSignal | undefined;
@@ -1,46 +0,0 @@
1
- // src/shared/resolve-fallbacks.ts
2
- function resolveFallbackChain(userFallbacks) {
3
- const userList = normalizeUserFallbacks(userFallbacks);
4
- return dedupe(userList.filter(isValidModelSpec));
5
- }
6
- function normalizeUserFallbacks(userFallbacks) {
7
- if (!userFallbacks)
8
- return [];
9
- if (typeof userFallbacks === "string") {
10
- const trimmed = userFallbacks.trim();
11
- return trimmed ? [trimmed] : [];
12
- }
13
- return userFallbacks.map((s) => s.trim()).filter((s) => s.length > 0);
14
- }
15
- function isValidModelSpec(spec) {
16
- const slash = spec.indexOf("/");
17
- return slash > 0 && slash < spec.length - 1;
18
- }
19
- function dedupe(list) {
20
- const seen = new Set;
21
- const out = [];
22
- for (const item of list) {
23
- if (seen.has(item))
24
- continue;
25
- seen.add(item);
26
- out.push(item);
27
- }
28
- return out;
29
- }
30
- function parseProviderModel(spec) {
31
- const slash = spec.indexOf("/");
32
- if (slash < 1 || slash >= spec.length - 1)
33
- return null;
34
- return {
35
- providerID: spec.slice(0, slash).trim(),
36
- modelID: spec.slice(slash + 1).trim()
37
- };
38
- }
39
- function modelBodyField(spec) {
40
- if (!spec)
41
- return {};
42
- const parsed = parseProviderModel(spec);
43
- return parsed ? { model: parsed } : {};
44
- }
45
-
46
- export { resolveFallbackChain, parseProviderModel, modelBodyField };
@@ -1,11 +0,0 @@
1
- import {
2
- modelBodyField,
3
- parseProviderModel,
4
- resolveFallbackChain
5
- } from "./index-td9depx5.js";
6
- import"./index-1yh8g550.js";
7
- export {
8
- modelBodyField,
9
- parseProviderModel,
10
- resolveFallbackChain
11
- };